#!/usr/bin/env python3 # -*- coding: utf-8 -*- # vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai _license__ = 'GPL v3' __docformat__ = 'restructuredtext en' import codecs import os, traceback, zipfile try: from PyQt5.Qt import QToolButton, QUrl except ImportError: from PyQt4.Qt import QToolButton, QUrl from calibre.gui2 import open_url, question_dialog from calibre.gui2.actions import InterfaceAction from calibre.utils.config import config_dir from calibre.ptempfile import (PersistentTemporaryDirectory, PersistentTemporaryFile, remove_dir) from calibre.ebooks.metadata.meta import get_metadata from calibre_plugins.obok_dedrm.dialogs import (SelectionDialog, DecryptAddProgressDialog, AddEpubFormatsProgressDialog, ResultsSummaryDialog) from calibre_plugins.obok_dedrm.config import plugin_prefs as cfg from calibre_plugins.obok_dedrm.__init__ import (PLUGIN_NAME, PLUGIN_SAFE_NAME, PLUGIN_VERSION, PLUGIN_DESCRIPTION, HELPFILE_NAME) from calibre_plugins.obok_dedrm.utilities import ( get_icon, set_plugin_icon_resources, showErrorDlg, format_plural, debug_print ) from calibre_plugins.obok_dedrm.obok.obok import KoboLibrary from calibre_plugins.obok_dedrm.obok.legacy_obok import legacy_obok PLUGIN_ICONS = ['images/obok.png'] try: debug_print("obok::action_err.py - loading translations") load_translations() except NameError: debug_print("obok::action_err.py - exception when loading translations") pass # load_translations() added in calibre 1.9 class InterfacePluginAction(InterfaceAction): name = PLUGIN_NAME action_spec = (PLUGIN_NAME, None, _(PLUGIN_DESCRIPTION), None) popup_type = QToolButton.InstantPopup action_type = 'current' def genesis(self): icon_resources = self.load_resources(PLUGIN_ICONS) set_plugin_icon_resources(PLUGIN_NAME, icon_resources) self.qaction.setIcon(get_icon(PLUGIN_ICONS[0])) self.qaction.triggered.connect(self.launchObok) self.gui.keyboard.finalize() def launchObok(self): ''' Main processing/distribution method ''' self.count = 0 self.books_to_add = [] self.formats_to_add = [] self.add_books_cancelled = False self.decryption_errors = [] self.userkeys = [] self.duplicate_book_list = [] self.no_home_for_book = [] self.ids_of_new_books = [] self.successful_format_adds =[] self.add_formats_cancelled = False self.tdir = PersistentTemporaryDirectory('_obok', prefix='') self.db = self.gui.current_db.new_api self.current_idx = self.gui.library_view.currentIndex() print ('Running {}'.format(PLUGIN_NAME + ' v' + PLUGIN_VERSION)) # # search for connected device in case serials are saved tmpserials = cfg['kobo_serials'] device_path = None try: device = self.parent().device_manager.connected_device if (device): device_path = device._main_prefix debug_print("get_device_settings - device_path=", device_path) else: debug_print("didn't find device") except: debug_print("Exception getting device path. Probably not an E-Ink Kobo device") # Get the Kobo Library object (obok v3.01) self.library = KoboLibrary(tmpserials, device_path, cfg['kobo_directory']) debug_print ("got kobodir %s" % self.library.kobodir) if (self.library.kobodir == ''): # linux and no device connected, but could be extended # to the case where on Windows/Mac the prog is not installed msg = _('

Could not find Kobo Library\n

Windows/Mac: do you have Kobo Desktop installed?\n

Windows/Mac/Linux: In case you have an Kobo eInk device, connect the device.') showErrorDlg(msg, None) return # Get a list of Kobo titles books = self.build_book_list() if len(books) < 1: msg = _('

No books found in Kobo Library\nAre you sure it\'s installed/configured/synchronized?') showErrorDlg(msg, None) return # Check to see if a key can be retrieved using the legacy obok method. legacy_key = legacy_obok().get_legacy_cookie_id if legacy_key is not None: print (_('Legacy key found: '), legacy_key.encode('hex_codec')) self.userkeys.append(legacy_key) # Add userkeys found through the normal obok method to the list to try. try: candidate_keys = self.library.userkeys except: print (_('Trouble retrieving keys with newer obok method.')) traceback.print_exc() else: if len(candidate_keys): self.userkeys.extend(candidate_keys) print (_('Found {0} possible keys to try.').format(len(self.userkeys))) if not len(self.userkeys): msg = _('

No userkeys found to decrypt books with. No point in proceeding.') showErrorDlg(msg, None) return # Launch the Dialog so the user can select titles. dlg = SelectionDialog(self.gui, self, books) if dlg.exec_(): books_to_import = dlg.getBooks() self.count = len(books_to_import) debug_print("InterfacePluginAction::launchObok - number of books to decrypt: %d" % self.count) # Feed the titles, the callback function (self.get_decrypted_kobo_books) # and the Kobo library object to the ProgressDialog dispatcher. d = DecryptAddProgressDialog(self.gui, books_to_import, self.get_decrypted_kobo_books, self.library, 'kobo', status_msg_type='Kobo books', action_type=('Decrypting', 'Decryption')) # Canceled the decryption process; clean up and exit. if d.wasCanceled(): print (_('{} - Decryption canceled by user.').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION)) self.library.close() remove_dir(self.tdir) return else: # Canceled the selection process; clean up and exit. self.library.close() remove_dir(self.tdir) return # Close Kobo Library object self.library.close() # If we have decrypted books to work with, feed the list of decrypted books details # and the callback function (self.add_new_books) to the ProgressDialog dispatcher. if len(self.books_to_add): d = DecryptAddProgressDialog(self.gui, self.books_to_add, self.add_new_books, self.db, 'calibre', status_msg_type='new calibre books', action_type=('Adding','Addition')) # Canceled the "add new books to calibre" process; # show the results of what got added before cancellation. if d.wasCanceled(): print (_('{} - "Add books" canceled by user.').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION)) self.add_books_cancelled = True print (_('{} - wrapping up results.').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION)) self.wrap_up_results() remove_dir(self.tdir) return # If books couldn't be added because of duplicate entries in calibre, ask # if we should try to add the decrypted epubs to existing calibre library entries. if len(self.duplicate_book_list): if cfg['finding_homes_for_formats'] == 'Always': self.process_epub_formats() elif cfg['finding_homes_for_formats'] == 'Never': self.no_home_for_book.extend([entry[0] for entry in self.duplicate_book_list]) else: if self.ask_about_inserting_epubs(): # Find homes for the epub decrypted formats in existing calibre library entries. self.process_epub_formats() else: print (_('{} - User opted not to try to insert EPUB formats').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION)) self.no_home_for_book.extend([entry[0] for entry in self.duplicate_book_list]) print (_('{} - wrapping up results.').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION)) self.wrap_up_results() remove_dir(self.tdir) return def show_help(self): ''' Extract on demand the help file resource ''' def get_help_file_resource(): # We will write the help file out every time, in case the user upgrades the plugin zip # and there is a newer help file contained within it. file_path = os.path.join(config_dir, 'plugins', HELPFILE_NAME) file_data = self.load_resources(HELPFILE_NAME)[HELPFILE_NAME].decode('utf-8') with open(file_path,'w') as f: f.write(file_data) return file_path url = 'file:///' + get_help_file_resource() open_url(QUrl(url)) def build_book_list(self): ''' Connect to Kobo db and get titles. ''' return self.library.books def get_decrypted_kobo_books(self, book): ''' This method is a call-back function used by DecryptAddProgressDialog in dialogs.py to decrypt Kobo books :param book: A KoboBook object that is to be decrypted. ''' print (_('{0} - Decrypting {1}').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION, book.title)) decrypted = self.decryptBook(book) if decrypted['success']: # Build a list of calibre "book maps" for calibre's add_book function. mi = get_metadata(decrypted['fileobj'], 'epub') bookmap = {'EPUB':decrypted['fileobj'].name} self.books_to_add.append((mi, bookmap)) else: # Book is probably still encrypted. print (_('{0} - Couldn\'t decrypt {1}').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION, book.title)) self.decryption_errors.append((book.title, _('decryption errors'))) return False return True def add_new_books(self, books_to_add): ''' This method is a call-back function used by DecryptAddProgressDialog in dialogs.py to add books to calibre (It's set up to handle multiple books, but will only be fed books one at a time by DecryptAddProgressDialog) :param books_to_add: List of calibre bookmaps (created in get_decrypted_kobo_books) ''' added = self.db.add_books(books_to_add, add_duplicates=False, run_hooks=False) if len(added[0]): # Record the id(s) that got added for id in added[0]: print (_('{0} - Added {1}').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION, books_to_add[0][0].title)) self.ids_of_new_books.append((id, books_to_add[0][0])) if len(added[1]): # Build a list of details about the books that didn't get added because duplicate were detected. for mi, map in added[1]: print (_('{0} - {1} already exists. Will try to add format later.').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION, mi.title)) self.duplicate_book_list.append((mi, map['EPUB'], _('duplicate detected'))) return False return True def add_epub_format(self, book_id, mi, path): ''' This method is a call-back function used by AddEpubFormatsProgressDialog in dialogs.py :param book_id: calibre ID of the book to add the encrypted epub to. :param mi: calibre metadata object :param path: path to the decrypted epub (temp file) ''' if self.db.add_format(book_id, 'EPUB', path, replace=False, run_hooks=False): self.successful_format_adds.append((book_id, mi)) print (_('{0} - Successfully added EPUB format to existing {1}').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION, mi.title)) return True # we really shouldn't get here. print (_('{0} - Error adding EPUB format to existing {1}. This really shouldn\'t happen.').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION, mi.title)) self.no_home_for_book.append(mi) return False def process_epub_formats(self): ''' Ask the user if they want to try to find homes for those books that already had an entry in calibre ''' for book in self.duplicate_book_list: mi, tmp_file = book[0], book[1] dup_ids = self.db.find_identical_books(mi) home_id = self.find_a_home(dup_ids) if home_id is not None: # Found an epub-free duplicate to add the epub to. # build a list for the add_epub_format method to use. self.formats_to_add.append((home_id, mi, tmp_file)) else: self.no_home_for_book.append(mi) # If we found homes for decrypted epubs in existing calibre entries, feed the list of decrypted book # details and the callback function (self.add_epub_format) to the ProgressDialog dispatcher. if self.formats_to_add: d = AddEpubFormatsProgressDialog(self.gui, self.formats_to_add, self.add_epub_format) if d.wasCanceled(): print (_('{} - "Insert formats" canceled by user.').format(PLUGIN_NAME + ' v' + PLUGIN_VERSION)) self.add_formats_cancelled = True return #return return def wrap_up_results(self): ''' Present the results ''' caption = PLUGIN_NAME + ' v' + PLUGIN_VERSION # Refresh the gui and highlight new entries/modified entries. if len(self.ids_of_new_books) or len(self.successful_format_adds): self.refresh_gui_lib() msg, log = self.build_report() sd = ResultsSummaryDialog(self.gui, caption, msg, log) sd.exec_() return def ask_about_inserting_epubs(self): ''' Build question dialog with details about kobo books that couldn't be added to calibre as new books. ''' ''' Terisa: Improve the message ''' caption = PLUGIN_NAME + ' v' + PLUGIN_VERSION plural = format_plural(len(self.ids_of_new_books)) det_msg = '' if self.count > 1: msg = _('

{0} EPUB{2} successfully added to library.

{1} ').format(len(self.ids_of_new_books), len(self.duplicate_book_list), plural) msg += _('not added because books with the same title/author were detected.

Would you like to try and add the EPUB format{0}').format(plural) msg += _(' to those existing entries?

NOTE: no pre-existing EPUBs will be overwritten.') for entry in self.duplicate_book_list: det_msg += _('{0} -- not added because of {1} in your library.\n\n').format(entry[0].title, entry[2]) else: msg = _('

{0} -- not added because of {1} in your library.

').format(self.duplicate_book_list[0][0].title, self.duplicate_book_list[0][2]) msg += _('Would you like to try and add the EPUB format to an available calibre duplicate?

') msg += _('NOTE: no pre-existing EPUB will be overwritten.') return question_dialog(self.gui, caption, msg, det_msg) def find_a_home(self, ids): ''' Find the ID of the first EPUB-Free duplicate available :param ids: List of calibre IDs that might serve as a home. ''' for id in ids: # Find the first entry that matches the incoming book that doesn't have an EPUB format. if not self.db.has_format(id, 'EPUB'): return id break return None def refresh_gui_lib(self): ''' Update the GUI; highlight the books that were added/modified ''' if self.current_idx.isValid(): self.gui.library_view.model().current_changed(self.current_idx, self.current_idx) new_entries = [id for id, mi in self.ids_of_new_books] if new_entries: self.gui.library_view.model().db.data.books_added(new_entries) self.gui.library_view.model().books_added(len(new_entries)) new_entries.extend([id for id, mi in self.successful_format_adds]) self.gui.db_images.reset() self.gui.tags_view.recount() self.gui.library_view.model().set_highlight_only(True) self.gui.library_view.select_rows(new_entries) return def decryptBook(self, book): ''' Decrypt Kobo book :param book: obok file object ''' result = {} result['success'] = False result['fileobj'] = None zin = zipfile.ZipFile(book.filename, 'r') #print ('Kobo library filename: {0}'.format(book.filename)) for userkey in self.userkeys: print (_('Trying key: '), codecs.encode(userkey, 'hex')) check = True try: fileout = PersistentTemporaryFile('.epub', dir=self.tdir) #print ('Temp file: {0}'.format(fileout.name)) # modify the output file to be compressed by default zout = zipfile.ZipFile(fileout.name, "w", zipfile.ZIP_DEFLATED) # ensure that the mimetype file is the first written to the epub container # and is stored with no compression members = zin.namelist(); try: members.remove('mimetype') except Exception: pass zout.writestr('mimetype', 'application/epub+zip', zipfile.ZIP_STORED) # end of mimetype mod for filename in members: contents = zin.read(filename) if filename in book.encryptedfiles: file = book.encryptedfiles[filename] contents = file.decrypt(userkey, contents) # Parse failures mean the key is probably wrong. if check: check = not file.check(contents) zout.writestr(filename, contents) zout.close() zin.close() result['success'] = True result['fileobj'] = fileout print ('Success!') return result except ValueError: print (_('Decryption failed, trying next key.')) zout.close() continue except Exception: print (_('Unknown Error decrypting, trying next key..')) zout.close() continue result['fileobj'] = book.filename zin.close() return result def build_report(self): log = '' processed = len(self.ids_of_new_books) + len(self.successful_format_adds) if processed == self.count: if self.count > 1: msg = _('

All selected Kobo books added as new calibre books or inserted into existing calibre ebooks.

No issues.') else: # Single book ... don't get fancy. title = self.ids_of_new_books[0][1].title if self.ids_of_new_books else self.successful_format_adds[0][1].title msg = _('

{0} successfully added.').format(title) return (msg, log) else: if self.count != 1: msg = _('

Not all selected Kobo books made it into calibre.

View report for details.') log += _('

Total attempted: {}

\n').format(self.count) log += _('

Decryption errors: {}

\n').format(len(self.decryption_errors)) if self.decryption_errors: log += '\n' log += _('

New Books created: {}

\n').format(len(self.ids_of_new_books)) if self.ids_of_new_books: log += '\n' if self.add_books_cancelled: log += _('

Duplicates that weren\'t added: {}

\n').format(len(self.duplicate_book_list)) if self.duplicate_book_list: log += '\n' cancelled_count = self.count - (len(self.decryption_errors) + len(self.ids_of_new_books) + len(self.duplicate_book_list)) if cancelled_count > 0: log += _('

Book imports cancelled by user: {}

\n').format(cancelled_count) return (msg, log) log += _('

New EPUB formats inserted in existing calibre books: {0}

\n').format(len(self.successful_format_adds)) if self.successful_format_adds: log += '\n' log += _('

EPUB formats NOT inserted into existing calibre books: {}
\n').format(len(self.no_home_for_book)) log += _('(Either because the user chose not to insert them, or because all duplicates already had an EPUB format)') if self.no_home_for_book: log += '

\n' if self.add_formats_cancelled: cancelled_count = self.count - (len(self.decryption_errors) + len(self.ids_of_new_books) + len(self.successful_format_adds) + len(self.no_home_for_book)) if cancelled_count > 0: log += _('

Format imports cancelled by user: {}

\n').format(cancelled_count) return (msg, log) else: # Single book ... don't get fancy. if self.ids_of_new_books: title = self.ids_of_new_books[0][1].title elif self.successful_format_adds: title = self.successful_format_adds[0][1].title elif self.no_home_for_book: title = self.no_home_for_book[0].title elif self.decryption_errors: title = self.decryption_errors[0][0] else: title = _('Unknown Book Title') if self.decryption_errors: reason = _('it couldn\'t be decrypted.') elif self.no_home_for_book: reason = _('user CHOSE not to insert the new EPUB format, or all existing calibre entries HAD an EPUB format already.') else: reason = _('of unknown reasons. Gosh I\'m embarrassed!') msg = _('

{0} not added because {1}').format(title, reason) return (msg, log)