diff --git a/__main__.py b/__main__.py index 1af88c7..7cee66c 100755 --- a/__main__.py +++ b/__main__.py @@ -24,9 +24,10 @@ import os import sys from PyQt5 import QtWidgets, QtGui, QtCore + +import book_parser import mainwindow import database -import parser class MainUI(QtWidgets.QMainWindow, mainwindow.Ui_MainWindow): @@ -35,8 +36,9 @@ class MainUI(QtWidgets.QMainWindow, mainwindow.Ui_MainWindow): self.setupUi(self) # Initialize application - Database(self) - Settings(self).read_settings() + Settings(self).read_settings() # This should populate all variables that need + # to be remembered across sessions + database.DatabaseInit(self.database_path) Toolbars(self) # New tabs and their contents @@ -52,8 +54,15 @@ class MainUI(QtWidgets.QMainWindow, mainwindow.Ui_MainWindow): self.tabWidget.tabBar().setTabButton(0, QtWidgets.QTabBar.RightSide, None) self.tabWidget.tabCloseRequested.connect(self.close_tab_class) + # ListView + self.listView.setSpacing(10) + self.reload_listview() self.listView.doubleClicked.connect(self.listclick) + # Keyboard shortcuts + self.exit_all = QtWidgets.QShortcut(QtGui.QKeySequence('Ctrl+Q'), self) + self.exit_all.activated.connect(QtWidgets.qApp.exit) + def create_tab_class(self): # TODO # Shift focus to tab if it's already open instead of creating @@ -70,9 +79,14 @@ class MainUI(QtWidgets.QMainWindow, mainwindow.Ui_MainWindow): self, 'Open file', self.last_open_path, "eBooks (*.epub *.mobi *.txt)") if my_file[0]: self.last_open_path = os.path.dirname(my_file[0][0]) - print(self.last_open_path) - books = parser.BookSorter(my_file[0]) - books.add_to_database() + books = book_parser.BookSorter(my_file[0]) + parsed_books = books.initiate_threads() + database.DatabaseFunctions(self.database_path).add_to_database(parsed_books) + self.reload_listview() + + def reload_listview(self): + lib_ref = Library(self) + lib_ref.load_listView() def close_tab_class(self, tab_index): this_tab = Tabs(self, None) @@ -105,47 +119,71 @@ class MainUI(QtWidgets.QMainWindow, mainwindow.Ui_MainWindow): self.show() self.current_textEdit.show() - def populatelist(self): - self.listView.setWindowTitle('huh') - - # The QlistView widget needs to be populated - # with a model that inherits from QStandardItemModel - model = QtGui.QStandardItemModel() - - # Get the list of images from here - # Temp dir this out after getting the images from the - # database - my_dir = os.path.join( - os.path.dirname(os.path.realpath(__file__)), 'thumbnails') - image_list = [os.path.join(my_dir, i) for i in os.listdir('./thumbnails')] - - # Generate image pixmap and then pass it to the widget - # as a QIcon - # Additional data can be set using an incrementing - # QtCore.Qt.UserRole - # QtCore.Qt.DisplayRole is the same as item.setText() - # The model is a single row and has no columns - for i in image_list: - img_pixmap = QtGui.QPixmap(i) - # item = QtGui.QStandardItem(i.split('/')[-1:][0][:-4]) - item = QtGui.QStandardItem() - item.setData('Additional data for ' + i.split('/')[-1:][0], QtCore.Qt.UserRole) - item.setIcon(QtGui.QIcon(img_pixmap)) - model.appendRow(item) - s = QtCore.QSize(200, 200) # Set icon sizing here - self.listView.setIconSize(s) - self.listView.setModel(model) - def listclick(self, myindex): # print('selected item index found at %s with data: %s' % (myindex.row(), myindex.data())) index = self.listView.model().index(myindex.row(), 0) print(self.listView.model().data(index, QtCore.Qt.UserRole)) - self.listView.setSpacing(10) def closeEvent(self, event): Settings(self).save_settings() +class Library: + def __init__(self, parent): + self.parent_window = parent + + def load_listView(self): + # TODO + # Make this take hints from the SortBy dropdown, the FilterBy lineEdit + # and the fetch_data method in the database module + # The rest of it is just refreshing the listview + + # The QlistView widget needs to be populated + # with a model that inherits from QStandardItemModel + model = QtGui.QStandardItemModel() + + books = database.DatabaseFunctions( + self.parent_window.database_path).fetch_data( + ('*',), + 'books', + {'Title': ''}, + 'LIKE') + + if not books: + print('Database returned nothing') + return + + # The database query returns a tuple with the following indices + # Index 0 is the key ID is is ignored + for i in books: + + book_title = i[1] + book_cover = i[6] + additional_data = { + 'book_path': i[2], + 'book_isbn': i[3], + 'book_tags': i[4], + 'book_hash': i[5]} + + # Generate image pixmap and then pass it to the widget + # as a QIcon + # Additional data can be set using an incrementing + # QtCore.Qt.UserRole + # 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) + item = QtGui.QStandardItem(book_title) + item.setData(additional_data, QtCore.Qt.UserRole) + item.setIcon(QtGui.QIcon(img_pixmap)) + model.appendRow(item) + + s = QtCore.QSize(200, 200) # Set icon sizing here + self.parent_window.listView.setIconSize(s) + self.parent_window.listView.setModel(model) + + class Settings: def __init__(self, parent): self.parent_window = parent @@ -161,10 +199,12 @@ class Settings: QtCore.QPoint(286, 141))) self.settings.endGroup() - self.settings.beginGroup('path') + self.settings.beginGroup('runtimeVariables') self.parent_window.last_open_path = self.settings.value( - 'path', os.path.expanduser('~')) - print(self.parent_window.last_open_path) + 'lastOpenPath', os.path.expanduser('~')) + self.parent_window.database_path = self.settings.value( + 'databasePath', + QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.AppDataLocation)) self.settings.endGroup() def save_settings(self): @@ -173,8 +213,9 @@ class Settings: self.settings.setValue('windowPosition', self.parent_window.pos()) self.settings.endGroup() - self.settings.beginGroup('lastOpen') - self.settings.setValue('path', self.parent_window.last_open_path) + self.settings.beginGroup('runtimeVariables') + self.settings.setValue('lastOpenPath', self.parent_window.last_open_path) + self.settings.setValue('databasePath', self.parent_window.database_path) self.settings.endGroup() @@ -207,7 +248,7 @@ class Toolbars: addButton.triggered.connect(self.parent_window.open_file) settingsButton.triggered.connect(self.parent_window.create_tab_class) - deleteButton.triggered.connect(self.parent_window.populatelist) + deleteButton.triggered.connect(self.parent_window.reload_listview) self.parent_window.LibraryToolBar.addAction(addButton) self.parent_window.LibraryToolBar.addAction(deleteButton) @@ -238,15 +279,6 @@ class Tabs: self.parent_window.tabWidget.removeTab(tab_index) -class Database: - # This is maybe, possibly, redundant - def __init__(self, parent): - self.parent_window = parent - self.database_path = QtCore.QStandardPaths.writableLocation( - QtCore.QStandardPaths.AppDataLocation) - self.db = database.DatabaseFunctions(self.database_path) - - def main(): app = QtWidgets.QApplication(sys.argv) app.setApplicationName('Lector') # This is needed for QStandardPaths diff --git a/parser.py b/book_parser.py similarity index 68% rename from parser.py rename to book_parser.py index a869539..371fe47 100644 --- a/parser.py +++ b/book_parser.py @@ -2,16 +2,22 @@ import os import re -import collections +import hashlib +from multiprocessing.dummy import Pool + import ebooklib.epub class ParseEPUB: def __init__(self, filename): + # TODO + # Maybe also include book description self.filename = filename - self.book_title = None + self.book = None + + def read_epub(self): try: - self.book = ebooklib.epub.read_epub(filename) + self.book = ebooklib.epub.read_epub(self.filename) except (KeyError, AttributeError): print('Cannot parse ' + self.filename) return @@ -20,6 +26,11 @@ class ParseEPUB: return self.book.title.strip() def get_cover_image(self): + # TODO + # Generate a cover image in case one isn't found + # This has to be done or the database module will + # error out + # Get cover image # This seems hack-ish, but that's never stopped me before image_path = None @@ -59,7 +70,6 @@ class ParseEPUB: return image_content except KeyError: - print('Cannot parse ' + self.filename) return def get_isbn(self): @@ -83,13 +93,39 @@ class BookSorter: # Parsing for the reader proper # Caching upon closing self.file_list = file_list + self.all_books = {} - def add_to_database(self): - # Consider multithreading this - for i in self.file_list: - book_ref = ParseEPUB(i) + def read_book(self, filename): + # filename is expected as a string containg the + # full path of the ebook file + + # TODO + # See if you want to include a hash of the book's name and author + with open(filename, 'rb') as current_book: + file_md5 = hashlib.md5(current_book.read()).hexdigest() + + if file_md5 in self.all_books.items(): + return + + # TODO + # See if tags can be generated from book content + book_ref = ParseEPUB(filename) + book_ref.read_epub() + if book_ref.book: title = book_ref.get_title() cover_image = book_ref.get_cover_image() isbn = book_ref.get_isbn() - print(title, isbn) + self.all_books[file_md5] = { + 'title': title, + 'isbn': isbn, + 'path': filename, + 'cover_image': cover_image} + + def initiate_threads(self): + _pool = Pool(5) + _pool.map(self.read_book, self.file_list) + _pool.close() + _pool.join() + + return self.all_books diff --git a/database.py b/database.py index 3c04756..861b252 100644 --- a/database.py +++ b/database.py @@ -3,21 +3,22 @@ import sqlite3 import os - -class DatabaseFunctions: +class DatabaseInit: def __init__(self, location_prefix): os.makedirs(location_prefix, exist_ok=True) - self.database_path = os.path.join( - location_prefix, 'Lector.db') + database_path = os.path.join(location_prefix, 'Lector.db') - self.database = sqlite3.connect(self.database_path) - if not os.path.exists(self.database_path): + if not os.path.exists(database_path): + self.database = sqlite3.connect(database_path) self.create_database() + else: + self.database = sqlite3.connect(database_path) def create_database(self): self.database.execute( "CREATE TABLE books \ - (id INTEGER PRIMARY KEY, Name TEXT, Path TEXT, ISBN TEXT, Tags TEXT, CoverImage BLOB)") + (id INTEGER PRIMARY KEY, Title TEXT, Path 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,5 +27,91 @@ class DatabaseFunctions: self.database.commit() - def add_to_database(self, book_data, image_data): - pass + +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 + # 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(): + book_hash = i[0] + book_title = i[1]['title'].replace("'", "") + book_path = i[1]['path'] + book_cover = i[1]['cover_image'] + book_isbn = i[1]['isbn'] + + # Check if the file might not already be in the database + hash_from_database = self.fetch_data( + ('Title',), + 'books', + {'Hash': book_hash}, + 'EQUALS', + True) + + sql_command_add = ( + "INSERT INTO books (Title,Path,ISBN,Hash,CoverImage) VALUES(?, ?, ?, ?, ?)") + + # TODO + # This is a placeholder. You will need to generate book covers + # in case none are found + if not hash_from_database and book_cover: + self.database.execute( + sql_command_add, + [book_title, book_path, book_isbn, book_hash, sqlite3.Binary(book_cover)]) + + self.database.commit() + + def fetch_data(self, columns, table, selection_criteria, equivalence, fetch_one=False): + # columns is a tuple that will be passed as a comma separated list + # table is a string that will be used as is + # selection_criteria is a dictionary which contains the name of a column linked + # to a corresponding value for selection + + # Example: + # Name and AltName are expected to be the same + # sel_dict = { + # 'Name': 'sav', + # 'AltName': 'sav' + # } + # data = DatabaseFunctions().fetch_data(('Name',), 'books', sel_dict) + try: + column_list = ','.join(columns) + sql_command_fetch = f"SELECT {column_list} FROM {table}" + if selection_criteria: + sql_command_fetch += " WHERE" + + if equivalence == 'EQUALS': + for i in selection_criteria.keys(): + search_parameter = selection_criteria[i] + sql_command_fetch += f" {i} = '{search_parameter}' OR" + + elif equivalence == 'LIKE': + for i in selection_criteria.keys(): + search_parameter = "'%" + selection_criteria[i] + "%'" + sql_command_fetch += f" {i} LIKE {search_parameter} OR" + + 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() + + if book_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 book_data + else: + return None + + # except sqlite3.OperationalError: + except KeyError: + print('SQLite is in rebellion, Commander')