diff --git a/__main__.py b/__main__.py index 0c3d606..6b9360d 100755 --- a/__main__.py +++ b/__main__.py @@ -5,6 +5,7 @@ Check files (hashes) upon restart Recursive file addition Show what on startup + If cache large files Library: ✓ sqlite3 for cover images cache ✓ sqlite3 for storing metadata @@ -13,8 +14,9 @@ ✓ Image reflow ✓ Search bar in toolbar ✓ Shift focus to the tab that has the book open + ? Create emblem per filetype + Look into how you might group icons Ignore a / the / numbers for sorting purposes - Maybe create emblem per filetype Put the path in the scope of the search maybe as a type: switch Mass tagging @@ -26,17 +28,18 @@ ✓ Override the keypress event of the textedit ✓ Use format* icons for toolbar buttons ✓ Implement book view settings with a(nother) toolbar - Consider substituting the textedit for another widget + ? Substitute textedit for another widget All ebooks should first be added to the database and then returned as HTML Pagination Theming Set context menu for definitions and the like Keep fontsize and margins consistent - Let page increase in length Filetypes: + ? Plugin system for parsers + ? pdf support epub support mobi, azw support txt, doc, djvu support - pdf support? cbz, cbr support Keep font settings enabled but only for background color Internet: @@ -44,7 +47,7 @@ Get ISBN using python-isbnlib Other: ✓ Define every widget in code - Maybe include icons for emblems + ? Include icons for emblems """ import os @@ -91,6 +94,7 @@ class MainUI(QtWidgets.QMainWindow, mainwindow.Ui_MainWindow): self.bookToolBar = BookToolBar(self) self.bookToolBar.fullscreenButton.triggered.connect(self.set_fullscreen) + self.bookToolBar.tocBox.activated.connect(self.set_toc_position) self.addToolBar(self.bookToolBar) # Make the correct toolbar visible @@ -99,10 +103,12 @@ class MainUI(QtWidgets.QMainWindow, mainwindow.Ui_MainWindow): # New tabs and their contents self.current_tab = None - self.current_textEdit = None + self.current_contentView = None # Tab closing self.tabWidget.setTabsClosable(True) + # TODO + # It's possible to add a widget to the Library tab here self.tabWidget.tabBar().setTabButton(0, QtWidgets.QTabBar.RightSide, None) self.tabWidget.tabCloseRequested.connect(self.close_tab) @@ -162,7 +168,7 @@ class MainUI(QtWidgets.QMainWindow, mainwindow.Ui_MainWindow): if my_file[0]: self.listView.setEnabled(False) self.last_open_path = os.path.dirname(my_file[0][0]) - books = sorter.BookSorter(my_file[0], self.database_path) + books = sorter.BookSorter(my_file[0], 'addition', self.database_path) parsed_books = books.initiate_threads() database.DatabaseFunctions(self.database_path).add_to_database(parsed_books) self.listView.setEnabled(True) @@ -176,11 +182,14 @@ class MainUI(QtWidgets.QMainWindow, mainwindow.Ui_MainWindow): if box_button.text() == '&Yes': selected_hashes = [] for i in selected_books: - book_data = i.data(QtCore.Qt.UserRole + 3) - selected_hashes.append(book_data['book_hash']) + data = i.data(QtCore.Qt.UserRole + 3) + selected_hashes.append(data['hash']) database.DatabaseFunctions( self.database_path).delete_from_database(selected_hashes) - self.viewModel = None + self.viewModel = None # TODO + # Delete the item from the model instead + # of reconstructing it + # The same goes for addition self.reload_listview() selected_number = len(selected_books) @@ -205,8 +214,10 @@ class MainUI(QtWidgets.QMainWindow, mainwindow.Ui_MainWindow): def tab_switch(self): if self.tabWidget.currentIndex() == 0: + self.bookToolBar.hide() self.libraryToolBar.show() + if self.lib_ref.proxy_model: # Making the proxy model available doesn't affect # memory utilization at all. Bleh. @@ -215,51 +226,76 @@ class MainUI(QtWidgets.QMainWindow, mainwindow.Ui_MainWindow): else: self.bookToolBar.show() self.libraryToolBar.hide() + current_metadata = self.tabWidget.widget( - self.tabWidget.currentIndex()).book_metadata - current_title = current_metadata['book_title'] - current_author = current_metadata['book_author'] + self.tabWidget.currentIndex()).metadata + + current_title = current_metadata['title'] + current_author = current_metadata['author'] + current_position = current_metadata['position'] + current_toc = current_metadata['content'].keys() + + self.bookToolBar.tocBox.blockSignals(True) + self.bookToolBar.tocBox.clear() + self.bookToolBar.tocBox.addItems(current_toc) + if current_position: + self.bookToolBar.tocBox.setCurrentIndex(current_position) + self.bookToolBar.tocBox.blockSignals(False) + self.statusMessage.setText( current_author + ' - ' + current_title) + def set_toc_position(self, event=None): + self.tabWidget.widget( + self.tabWidget.currentIndex()).metadata[ + 'position'] = event + + chapter_name = self.bookToolBar.tocBox.currentText() + + current_tab = self.tabWidget.widget(self.tabWidget.currentIndex()) + required_content = current_tab.metadata['content'][chapter_name] + current_tab.contentView.setHtml(required_content) + def set_fullscreen(self): self.current_tab = self.tabWidget.currentIndex() - self.current_textEdit = self.tabWidget.widget(self.current_tab) + self.current_contentView = self.tabWidget.widget(self.current_tab) self.exit_shortcut = QtWidgets.QShortcut( - QtGui.QKeySequence('Escape'), self.current_textEdit) + QtGui.QKeySequence('Escape'), self.current_contentView) self.exit_shortcut.activated.connect(self.set_normalsize) - self.current_textEdit.setWindowFlags(QtCore.Qt.Window) - self.current_textEdit.setWindowState(QtCore.Qt.WindowFullScreen) + self.current_contentView.setWindowFlags(QtCore.Qt.Window) + self.current_contentView.setWindowState(QtCore.Qt.WindowFullScreen) self.hide() - self.current_textEdit.show() + self.current_contentView.show() def set_normalsize(self): - self.current_textEdit.setWindowState(QtCore.Qt.WindowNoState) - self.current_textEdit.setWindowFlags(QtCore.Qt.Widget) + self.current_contentView.setWindowState(QtCore.Qt.WindowNoState) + self.current_contentView.setWindowFlags(QtCore.Qt.Widget) self.show() - self.current_textEdit.show() + self.current_contentView.show() def list_doubleclick(self, myindex): - # TODO - # Load the book. index = self.listView.model().index(myindex.row(), 0) - book_metadata = self.listView.model().data(index, QtCore.Qt.UserRole + 3) + metadata = self.listView.model().data(index, QtCore.Qt.UserRole + 3) # Shift focus to the tab that has the book open (if there is one) for i in range(1, self.tabWidget.count()): - tab_book_metadata = self.tabWidget.widget(i).book_metadata - if tab_book_metadata['book_hash'] == book_metadata['book_hash']: + tab_metadata = self.tabWidget.widget(i).metadata + if tab_metadata['hash'] == metadata['hash']: self.tabWidget.setCurrentIndex(i) return - tab_ref = Tab(book_metadata, self.tabWidget) + path = metadata['path'] + contents = sorter.BookSorter( + [path], 'reading', self.database_path).initiate_threads() + + tab_ref = Tab(contents, self.tabWidget) self.tabWidget.setCurrentWidget(tab_ref) - print(tab_ref.book_metadata) # Metadata upon tab creation + # print(tab_ref.book_metadata) # Metadata upon tab creation def close_tab(self, tab_index): - print(self.tabWidget.widget(tab_index).book_metadata) # Metadata upon tab deletion + # print(self.tabWidget.widget(tab_index).metadata) # Metadata upon tab deletion self.tabWidget.removeTab(tab_index) def closeEvent(self, event=None): diff --git a/database.py b/database.py index 61f1cfb..ad115a4 100644 --- a/database.py +++ b/database.py @@ -3,6 +3,7 @@ import sqlite3 import os + class DatabaseInit: def __init__(self, location_prefix): os.makedirs(location_prefix, exist_ok=True) @@ -16,7 +17,7 @@ class DatabaseInit: self.database.execute( "CREATE TABLE books \ (id INTEGER PRIMARY KEY, Title TEXT, Author TEXT, Year INTEGER, \ - Path TEXT, ISBN TEXT, Tags TEXT, Hash TEXT, CoverImage BLOB)") + Path TEXT, Position TEXT, ISBN TEXT, Tags TEXT, Hash TEXT, CoverImage BLOB)") self.database.execute( "CREATE TABLE cache \ (id INTEGER PRIMARY KEY, Name TEXT, Path TEXT, CachedDict BLOB)") @@ -26,28 +27,29 @@ class DatabaseInit: self.database.commit() self.database.close() + class DatabaseFunctions: def __init__(self, location_prefix): database_path = os.path.join(location_prefix, 'Lector.db') self.database = sqlite3.connect(database_path) - def add_to_database(self, book_data): - # book_data is expected to be a dictionary + def add_to_database(self, data): + # data is expected to be a dictionary # with keys corresponding to the book hash # and corresponding items containing # whatever else needs insertion # Haha I said insertion - for i in book_data.items(): + for i in data.items(): book_hash = i[0] - book_title = i[1]['title'] - book_author = i[1]['author'] - book_year = i[1]['year'] - if not book_year: - book_year = 9999 - book_path = i[1]['path'] - book_cover = i[1]['cover_image'] - book_isbn = i[1]['isbn'] + title = i[1]['title'] + author = i[1]['author'] + year = i[1]['year'] + if not year: + year = 9999 + path = i[1]['path'] + cover = i[1]['cover_image'] + isbn = i[1]['isbn'] sql_command_add = ( "INSERT INTO books (Title,Author,Year,Path,ISBN,Hash,CoverImage) VALUES(?, ?, ?, ?, ?, ?, ?)") @@ -55,11 +57,11 @@ class DatabaseFunctions: # TODO # This is a placeholder. You will need to generate book covers # in case none are found - if book_cover: + if cover: self.database.execute( sql_command_add, - [book_title, book_author, book_year, - book_path, book_isbn, book_hash, sqlite3.Binary(book_cover)]) + [title, author, year, + path, isbn, book_hash, sqlite3.Binary(cover)]) self.database.commit() @@ -95,15 +97,15 @@ class DatabaseFunctions: sql_command_fetch = sql_command_fetch[:-3] # Truncate the last OR # book data is returned as a list of tuples - book_data = self.database.execute(sql_command_fetch).fetchall() + data = self.database.execute(sql_command_fetch).fetchall() - if book_data: + if data: # Because this is the result of a fetchall(), we need an # ugly hack (tm) to get correct results if fetch_one: - return book_data[0][0] + return data[0][0] - return book_data + return data else: return None diff --git a/parsers/epub.py b/parsers/epub.py index ab365f6..3bb288b 100644 --- a/parsers/epub.py +++ b/parsers/epub.py @@ -10,6 +10,7 @@ import os import re +import collections import ebooklib.epub @@ -24,7 +25,7 @@ class ParseEPUB: def read_book(self): try: self.book = ebooklib.epub.read_epub(self.filename) - except (KeyError, AttributeError): + except (KeyError, AttributeError, FileNotFoundError): print('Cannot parse ' + self.filename) return @@ -100,3 +101,40 @@ class ParseEPUB: return isbn except KeyError: return + + def get_contents(self): + contents = collections.OrderedDict() + + def flatten_chapter(toc_element): + output_list = [] + for i in toc_element: + if isinstance(i, (tuple, list)): + output_list.extend(flatten_chapter(i)) + else: + output_list.append(i) + return output_list + + for i in self.book.toc: + if isinstance(i, (tuple, list)): + title = i[0].title + contents[title] = 'Composite Chapter' + # composite_chapter = flatten_chapter(i) + # composite_chapter_content = [] + # for j in composite_chapter: + # href = j.href + # composite_chapter_content.append( + # self.book.get_item_with_href(href).get_content()) + + # contents[title] = composite_chapter_content + else: + title = i.title + href = i.href + try: + content = self.book.get_item_with_href(href).get_content() + if content: + contents[title] = content.decode() + else: + raise AttributeError + except AttributeError: + contents[title] = '' + return contents diff --git a/sorter.py b/sorter.py index daac2f8..fe16a96 100644 --- a/sorter.py +++ b/sorter.py @@ -1,16 +1,20 @@ #!/usr/bin/env python3 # TODO -# Methods that return None must be quantified here if needed +# Methods that return None must be quantified within the parsing module +# See if tags can be generated from book content +# See if you want to include a hash of the book's name and author +import os import hashlib from multiprocessing.dummy import Pool import database from parsers.epub import ParseEPUB + class BookSorter: - def __init__(self, file_list, database_path): + def __init__(self, file_list, mode, database_path): # Have the GUI pass a list of files straight to here # Then, on the basis of what is needed, pass the # filenames to the requisite functions @@ -21,7 +25,9 @@ class BookSorter: self.all_books = {} self.database_path = database_path self.hashes = [] - self.database_hashes() + self.mode = mode + if database_path: + self.database_hashes() def database_hashes(self): all_hashes = database.DatabaseFunctions( @@ -34,6 +40,16 @@ class BookSorter: if all_hashes: self.hashes = [i[0] for i in all_hashes] + def database_position(self, file_hash): + position = database.DatabaseFunctions( + self.database_path).fetch_data( + ('Position',), + 'books', + {'Hash': file_hash}, + 'EQUALS', + True) + return position + def read_book(self, filename): # filename is expected as a string containg the # full path of the ebook file @@ -41,17 +57,21 @@ class BookSorter: with open(filename, 'rb') as current_book: file_md5 = hashlib.md5(current_book.read()).hexdigest() + # IF the file is NOT being loaded into the reader, # Do not allow addition in case the file is dupicated in the directory # OR is already in the database - # TODO - # See if you want to include a hash of the book's name and author - if file_md5 in self.all_books.items() or file_md5 in self.hashes: + # This should not get triggered in reading mode + if (self.mode == 'addition' + and (file_md5 in self.all_books.items() or file_md5 in self.hashes)): return - # TODO - # See if tags can be generated from book content - # Sort according to to file extension here - book_ref = ParseEPUB(filename) + # Select sorter by file extension + try: + file_extension = os.path.splitext(filename)[1][1:] + if file_extension == 'epub': + book_ref = ParseEPUB(filename) + except IndexError: + return # Everything following this is standard # Some of the None returns will have to have @@ -61,16 +81,32 @@ class BookSorter: title = book_ref.get_title() author = book_ref.get_author() year = book_ref.get_year() - cover_image = book_ref.get_cover_image() isbn = book_ref.get_isbn() - self.all_books[file_md5] = { - 'title': title, - 'author': author, - 'year': year, - 'isbn': isbn, - 'path': filename, - 'cover_image': cover_image} + # Different modes require different values + if self.mode == 'addition': + cover_image = book_ref.get_cover_image() + self.all_books[file_md5] = { + 'title': title, + 'author': author, + 'year': year, + 'isbn': isbn, + 'path': filename, + 'cover_image': cover_image} + + if self.mode == 'reading': + content = book_ref.get_contents() + position = self.database_position(file_md5) + self.all_books = { + 'title': title, + 'author': author, + 'year': year, + 'isbn': isbn, + 'hash': file_md5, + 'path': filename, + 'position': position, + 'content': content} + def initiate_threads(self): _pool = Pool(5) diff --git a/subclasses.py b/subclasses.py index c1a2567..4a78874 100644 --- a/subclasses.py +++ b/subclasses.py @@ -31,51 +31,52 @@ class Library: for i in books: # The database query returns a tuple with the following indices # Index 0 is the key ID is ignored - book_title = i[1] - book_author = i[2] - book_year = i[3] - book_cover = i[8] - book_tags = i[6] - book_path = i[4] - book_progress = None # TODO - # Leave at None for an untouched book - # 'completed' for a completed book - # whatever else is here can be used - # to remember position + title = i[1] + author = i[2] + year = i[3] + path = i[4] + tags = i[6] + cover = i[9] + progress = None # TODO + # Leave at None for an untouched book + # 'completed' for a completed book + # whatever else is here can be used + # to remember position + # Maybe get from the position param all_metadata = { - 'book_title': i[1], - 'book_author': i[2], - 'book_year': i[3], - 'book_path': i[4], - 'book_isbn': i[5], - 'book_tags': i[6], - 'book_hash': i[7]} + 'title': i[1], + 'author': i[2], + 'year': i[3], + 'path': i[4], + 'position': i[5], + 'isbn': i[6], + 'tags': i[7], + 'hash': i[8]} - tooltip_string = book_title + '\nAuthor: ' + book_author + '\nYear: ' + str(book_year) - if book_tags: - tooltip_string += ('\nTags: ' + book_tags) + tooltip_string = title + '\nAuthor: ' + author + '\nYear: ' + str(year) + if tags: + tooltip_string += ('\nTags: ' + tags) # This remarkably ugly hack is because the QSortFilterProxyModel # doesn't easily allow searching through multiple item roles - search_workaround = book_title + ' ' + book_author - if book_tags: - search_workaround += book_tags + search_workaround = title + ' ' + author + if tags: + search_workaround += tags # Generate book state for passing onto the QStyledItemDelegate - def generate_book_state(book_path, book_progress): - if not os.path.exists(book_path): + def generate_book_state(path, progress): + if not os.path.exists(path): return 'deleted' - if book_progress: - if book_progress == 'completed': + if progress: + if progress == 'completed': return 'completed' else: return 'inprogress' else: return None - - book_state = generate_book_state(book_path, book_progress) + state = generate_book_state(path, progress) # Generate image pixmap and then pass it to the widget # as a QIcon @@ -84,17 +85,17 @@ class Library: # QtCore.Qt.DisplayRole is the same as item.setText() # The model is a single row and has no columns img_pixmap = QtGui.QPixmap() - img_pixmap.loadFromData(book_cover) + img_pixmap.loadFromData(cover) img_pixmap = img_pixmap.scaled(420, 600, QtCore.Qt.IgnoreAspectRatio) item = QtGui.QStandardItem() item.setToolTip(tooltip_string) # The following order is needed to keep sorting working - item.setData(book_title, QtCore.Qt.UserRole) - item.setData(book_author, QtCore.Qt.UserRole + 1) - item.setData(book_year, QtCore.Qt.UserRole + 2) + item.setData(title, QtCore.Qt.UserRole) + item.setData(author, QtCore.Qt.UserRole + 1) + item.setData(year, QtCore.Qt.UserRole + 2) item.setData(all_metadata, QtCore.Qt.UserRole + 3) item.setData(search_workaround, QtCore.Qt.UserRole + 4) - item.setData(book_state, QtCore.Qt.UserRole + 5) + item.setData(state, QtCore.Qt.UserRole + 5) item.setIcon(QtGui.QIcon(img_pixmap)) self.parent_window.viewModel.appendRow(item) @@ -108,7 +109,8 @@ class Library: def update_proxymodel(self): self.proxy_model.setFilterRole(QtCore.Qt.UserRole + 4) self.proxy_model.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) - self.proxy_model.setFilterWildcard(self.parent_window.libraryToolBar.filterEdit.text()) + self.proxy_model.setFilterWildcard( + self.parent_window.libraryToolBar.filterEdit.text()) self.parent_window.statusMessage.setText( str(self.proxy_model.rowCount()) + ' books') diff --git a/widgets.py b/widgets.py index e424a19..6f0ea54 100644 --- a/widgets.py +++ b/widgets.py @@ -97,9 +97,7 @@ class BookToolBar(QtWidgets.QToolBar): self.searchBar.setObjectName('searchBar') # Sorter - sorting_choices = ['Chapter ' + str(i) for i in range(1, 11)] self.tocBox = QtWidgets.QComboBox() - self.tocBox.addItems(sorting_choices) self.tocBox.setObjectName('sortingBox') self.tocBox.setSizePolicy(sizePolicy) self.tocBox.setMinimumContentsLength(10) @@ -216,27 +214,31 @@ class LibraryToolBar(QtWidgets.QToolBar): class Tab(QtWidgets.QWidget): - def __init__(self, book_metadata, parent=None): + def __init__(self, metadata, parent=None): # TODO - # The display widget will probably have to be shifted to something else # A horizontal slider to control flow # Keyboard shortcuts + # The content display widget is currently a QTextBrowser super(Tab, self).__init__(parent) self.parent = parent - self.book_metadata = book_metadata # Save progress data into this dictionary + self.metadata = metadata # Save progress data into this dictionary + self.setStyleSheet("background-color: black") - book_title = self.book_metadata['book_title'] - book_path = self.book_metadata['book_path'] + title = self.metadata['title'] + path = self.metadata['path'] self.gridLayout = QtWidgets.QGridLayout(self) self.gridLayout.setObjectName("gridLayout") - self.textEdit = QtWidgets.QTextEdit(self) - self.textEdit.setObjectName("textEdit") - self.textEdit.setFrameShape(QtWidgets.QFrame.NoFrame) - self.gridLayout.addWidget(self.textEdit, 0, 0, 1, 1) - self.parent.addTab(self, book_title) - self.textEdit.setText(book_path) + self.contentView = QtWidgets.QTextBrowser(self) + self.contentView.setFrameShape(QtWidgets.QFrame.NoFrame) + self.contentView.setObjectName("contentView") + self.contentView.verticalScrollBar().setSingleStep(7) + self.contentView.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.gridLayout.addWidget(self.contentView, 0, 0, 1, 1) + self.parent.addTab(self, title) + self.contentView.setStyleSheet( + "QTextEdit {font-size:20px; padding-left:100; padding-right:100; background-color:black}") class LibraryDelegate(QtWidgets.QStyledItemDelegate): @@ -246,13 +248,13 @@ class LibraryDelegate(QtWidgets.QStyledItemDelegate): def paint(self, painter, option, index): QtWidgets.QStyledItemDelegate.paint(self, painter, option, index) option = option.__class__(option) - book_state = index.data(QtCore.Qt.UserRole + 5) - if book_state: - if book_state == 'deleted': + state = index.data(QtCore.Qt.UserRole + 5) + if state: + if state == 'deleted': read_icon = QtGui.QIcon.fromTheme('vcs-conflicting').pixmap(36) - if book_state == 'completed': + if state == 'completed': read_icon = QtGui.QIcon.fromTheme('vcs-normal').pixmap(36) - if book_state == 'inprogress': + if state == 'inprogress': read_icon = QtGui.QIcon.fromTheme('vcs-locally-modified').pixmap(36) else: return