diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 9a27807..e27eea5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,16 +35,16 @@ body: id: calibre-version attributes: label: Which version of Calibre are you running? - description: "Example: 6.2.1" - placeholder: "6.2.1" + description: "Example: 6.4" + placeholder: "6.4" validations: required: true - type: input id: plugin-version attributes: label: Which version of the DeACSM plugin are you running? - description: "Example: v0.0.16" - placeholder: "v0.0.16" + description: "Example: v0.0.17" + placeholder: "v0.0.17" validations: required: true - type: textarea diff --git a/calibre-plugin/__init__.py b/calibre-plugin/__init__.py index 4d49e70..4eb4b3a 100644 --- a/calibre-plugin/__init__.py +++ b/calibre-plugin/__init__.py @@ -43,10 +43,11 @@ # update python-oscrypto to unofficial fork to fix OpenSSL 3 support. # In Progress: # Fix bug that would sometimes return the wrong book (or none at all) if you had -# multiple active loans from the same distributor. +# multiple active loans from the same distributor, add experimental GUI button, +# rename plugin from "DeACSM" to "ACSM Input" -PLUGIN_NAME = "DeACSM" +PLUGIN_NAME = "ACSM Input" PLUGIN_VERSION_TUPLE = (0, 0, 16) from calibre.customize import FileTypePlugin # type: ignore @@ -60,10 +61,11 @@ from calibre.constants import isosx, iswindows, islinux # type: import os, shutil, traceback, sys, time, io, random import zipfile from lxml import etree +from calibre.gui2 import error_dialog #@@CALIBRE_COMPAT_CODE@@ -class DeACSM(FileTypePlugin): +class ACSMInput(FileTypePlugin): name = PLUGIN_NAME description = "ACSM Input Plugin - Takes an Adobe ACSM file and converts that into a useable EPUB or PDF file. Python reimplementation of libgourou by Grégory Soutadé" supported_platforms = ['linux', 'osx', 'windows'] @@ -75,7 +77,35 @@ class DeACSM(FileTypePlugin): on_preprocess = True priority = 2000 + def init_embedded_plugins(self): + """ + A Calibre plugin can normally only contain one Plugin class. + In our case, this would be the DeACSM class. + However, we want to load the GUI plugin, too, so we have to trick + Calibre into believing that there's actually a 2nd plugin. + """ + from calibre.customize.ui import _initialized_plugins + from calibre_plugins.deacsm.gui_main_wrapper import DeACSMGUIExtension + + def init_plg(plg_type): + for plugin in _initialized_plugins: + if isinstance(plugin, plg_type): + return plugin + + plg_type.version = self.version + plg_type.minimum_calibre_version = self.minimum_calibre_version + plugin = plg_type(self.plugin_path) + _initialized_plugins.append(plugin) + plugin.initialize() + + return plugin + + init_plg(DeACSMGUIExtension) + + + def initialize(self): + """ On initialization, make sure we have all the libraries (oscrypto and its dependency asn1crypto) that the plugin needs. Unfortunately the Adobe encryption is kinda weird @@ -98,7 +128,49 @@ class DeACSM(FileTypePlugin): self.pluginsdir = os.path.join(config_dir,"plugins") if not os.path.exists(self.pluginsdir): os.mkdir(self.pluginsdir) - self.maindir = os.path.join(self.pluginsdir,"DeACSM") + + # Okay, "I" am now the new version. If I'm running under the old name, + # move "me" to the new one. + if os.path.exists(os.path.join(self.pluginsdir, "DeACSM.zip")): + + from calibre.customize.ui import _config + + shutil.copyfile(os.path.join(self.pluginsdir, "DeACSM.zip"), os.path.join(self.pluginsdir, "ACSM Input.zip")) + + # Delete the old plugin. + os.remove(os.path.join(self.pluginsdir, "DeACSM.zip")) + + # Forcibly add the new plugin, circumventing the Calibre code. + ui_plg_config = _config() + plugins = ui_plg_config['plugins'] + plugins["ACSM Input"] = os.path.join(self.pluginsdir, "ACSM Input.zip") + ui_plg_config['plugins'] = plugins + + return + + + # Make sure the GUI extension is loaded: + self.init_embedded_plugins() + + + + + self.maindir_old = os.path.join(self.pluginsdir,"DeACSM") + self.maindir = os.path.join(self.pluginsdir,"ACSMInput") + + if os.path.exists(self.maindir_old) and not os.path.exists(self.maindir): + # Migrate config to new folder + os.rename(self.maindir_old, self.maindir) + if not iswindows: + # Linux and Mac support symlinks, so create one so the old paths + # still work and people can downgrade the plugin again. + # Windows ... doesn't, so downgrading will be tricky. + try: + os.symlink(self.maindir_old, self.maindir) + except: + pass + + if not os.path.exists(self.maindir): os.mkdir(self.maindir) @@ -222,7 +294,7 @@ class DeACSM(FileTypePlugin): import calibre_plugins.deacsm.prefs as prefs # type: ignore - deacsmprefs = prefs.DeACSM_Prefs() + deacsmprefs = prefs.ACSMInput_Prefs() update_account_path(deacsmprefs["path_to_account_data"]) except Exception as e: @@ -242,7 +314,7 @@ class DeACSM(FileTypePlugin): def ADE_sanity_check(self): import calibre_plugins.deacsm.prefs as prefs # type: ignore - deacsmprefs = prefs.DeACSM_Prefs() + deacsmprefs = prefs.ACSMInput_Prefs() from libadobe import get_activation_xml_path @@ -403,7 +475,7 @@ class DeACSM(FileTypePlugin): print("{0} v{1}: Try to fulfill ...".format(PLUGIN_NAME, PLUGIN_VERSION)) import calibre_plugins.deacsm.prefs as prefs # type: ignore - deacsmprefs = prefs.DeACSM_Prefs() + deacsmprefs = prefs.ACSMInput_Prefs() success, replyData = fulfill(path_to_ebook, deacsmprefs["notify_fulfillment"]) diff --git a/calibre-plugin/acsm_logo_2.png b/calibre-plugin/acsm_logo_2.png new file mode 100644 index 0000000..f26ced3 Binary files /dev/null and b/calibre-plugin/acsm_logo_2.png differ diff --git a/calibre-plugin/config.py b/calibre-plugin/config.py index f3bb3c6..3680546 100644 --- a/calibre-plugin/config.py +++ b/calibre-plugin/config.py @@ -46,7 +46,7 @@ class ConfigWidget(QWidget): self.plugin_path = plugin_path # get the prefs - self.deacsmprefs = prefs.DeACSM_Prefs() + self.deacsmprefs = prefs.ACSMInput_Prefs() # make a local copy self.tempdeacsmprefs = {} @@ -58,7 +58,6 @@ class ConfigWidget(QWidget): self.tempdeacsmprefs['list_of_rented_books'] = self.deacsmprefs['list_of_rented_books'] - # Start Qt Gui dialog layout layout = QVBoxLayout(self) self.setLayout(layout) @@ -150,7 +149,13 @@ class ConfigWidget(QWidget): self.button_rented_books.setEnabled(activated) ua_group_box_layout.addWidget(self.button_rented_books) - if (len(self.deacsmprefs["list_of_rented_books"]) == 0): + + # First remove all overdue books from the loan list, + # to determine if we should enable the button. + self.delete_overdue_books_from_loan_list() + + if (len(self.tempdeacsmprefs["list_of_rented_books"]) == 0): + self.button_rented_books.setText(_("No loaned books available")) self.button_rented_books.setEnabled(False) @@ -1275,17 +1280,47 @@ class ConfigWidget(QWidget): def show_rented_books(self): - d = RentedBooksDialog(self, self.deacsmprefs["list_of_rented_books"]) + self.delete_overdue_books_from_loan_list() + + d = RentedBooksDialog(self) d.exec_() + def delete_overdue_books_from_loan_list(self): + overdue_books = [] + + for book in self.deacsmprefs["list_of_rented_books"]: + try: + book_time_stamp = book["validUntil"] + timestamp = datetime.datetime.strptime(book_time_stamp, "%Y-%m-%dT%H:%M:%SZ") + currenttime = datetime.datetime.utcnow() + except: + # Invalid book timestano + continue + + if (timestamp <= currenttime): + # Book is overdue, no need to return. Delete from list. + overdue_books.append(book) + continue + + templist = self.deacsmprefs["list_of_rented_books"] + + for book in overdue_books: + templist.remove(book) + + self.deacsmprefs.set("list_of_rented_books", templist) + self.deacsmprefs.writeprefs() + + class RentedBooksDialog(QDialog): - def __init__(self, parent, booklist): + def __init__(self, parent): QDialog.__init__(self,parent) self.parent = parent self.setWindowTitle("DeACSM: Manage loaned Books") + self.deacsmprefs = prefs.ACSMInput_Prefs() + # Start Qt Gui dialog layout layout = QVBoxLayout(self) self.setLayout(layout) @@ -1353,37 +1388,22 @@ class RentedBooksDialog(QDialog): def populate_list(self): self.listy.clear() - overdue_books = [] - - for book in self.parent.deacsmprefs["list_of_rented_books"]: + for book in self.deacsmprefs["list_of_rented_books"]: try: book_time_stamp = book["validUntil"] timestamp = datetime.datetime.strptime(book_time_stamp, "%Y-%m-%dT%H:%M:%SZ") currenttime = datetime.datetime.utcnow() except: - # Invalid book timestano + # Invalid book timestamp continue - - if (timestamp <= currenttime): - # Book is overdue, no need to return. Delete from list. - overdue_books.append(book) - continue - else: - info = "(" + self.td_format(timestamp - currenttime) - info += " remaining)" - + info = "(" + self.td_format(timestamp - currenttime) + " remaining)" item = QListWidgetItem(book["book_name"] + " " + info) item.setData(QtCore.Qt.UserRole, book["loanID"]) self.listy.addItem(item) - for book in overdue_books: - self.parent.deacsmprefs["list_of_rented_books"].remove(book) - - self.parent.deacsmprefs.writeprefs() - def return_book(self): if not self.listy.currentItem(): @@ -1400,7 +1420,7 @@ class RentedBooksDialog(QDialog): traceback.print_exc() Ret_book = None - for book in self.parent.deacsmprefs["list_of_rented_books"]: + for book in self.deacsmprefs["list_of_rented_books"]: if book["loanID"] == userdata: Ret_book = book break @@ -1430,14 +1450,16 @@ class RentedBooksDialog(QDialog): success = False done = False + templist = self.deacsmprefs["list_of_rented_books"] while not done: done = True - for book in self.parent.deacsmprefs["list_of_rented_books"]: + for book in templist: if book["loanID"] == userdata: done = False - self.parent.deacsmprefs["list_of_rented_books"].remove(book) + templist.remove(book) success = True break + self.deacsmprefs.set("list_of_rented_books", templist) self.populate_list() diff --git a/calibre-plugin/exportPluginAuthToWindowsADE.py b/calibre-plugin/exportPluginAuthToWindowsADE.py index 4415df2..d2a2c54 100644 --- a/calibre-plugin/exportPluginAuthToWindowsADE.py +++ b/calibre-plugin/exportPluginAuthToWindowsADE.py @@ -101,7 +101,7 @@ def GetMasterKey(): verbose_logging = False try: import calibre_plugins.deacsm.prefs as prefs - deacsmprefs = prefs.DeACSM_Prefs() + deacsmprefs = prefs.ACSMInput_Prefs() verbose_logging = deacsmprefs["detailed_logging"] except: pass diff --git a/calibre-plugin/getEncryptionKeyLinux.py b/calibre-plugin/getEncryptionKeyLinux.py index ee46351..c659486 100644 --- a/calibre-plugin/getEncryptionKeyLinux.py +++ b/calibre-plugin/getEncryptionKeyLinux.py @@ -11,7 +11,7 @@ def GetMasterKey(wineprefix): verbose_logging = False try: import calibre_plugins.deacsm.prefs as prefs - deacsmprefs = prefs.DeACSM_Prefs() + deacsmprefs = prefs.ACSMInput_Prefs() verbose_logging = deacsmprefs["detailed_logging"] except: pass diff --git a/calibre-plugin/getEncryptionKeyWindows.py b/calibre-plugin/getEncryptionKeyWindows.py index 5ee41e6..74874ec 100644 --- a/calibre-plugin/getEncryptionKeyWindows.py +++ b/calibre-plugin/getEncryptionKeyWindows.py @@ -115,7 +115,7 @@ def GetMasterKey(): verbose_logging = False try: import calibre_plugins.deacsm.prefs as prefs - deacsmprefs = prefs.DeACSM_Prefs() + deacsmprefs = prefs.ACSMInput_Prefs() verbose_logging = deacsmprefs["detailed_logging"] except: pass diff --git a/calibre-plugin/gui_main.py b/calibre-plugin/gui_main.py new file mode 100644 index 0000000..e6cd9b3 --- /dev/null +++ b/calibre-plugin/gui_main.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# GUI for the ACSM plugin. +# +# "create_menu_action_unique" taken from the Quality Check plugin: +# GPLv3, Copyright 2011, Grant Drake + + +from calibre.gui2.actions import InterfaceAction +from calibre.gui2.actions import menu_action_unique_name +from PyQt5.QtGui import QMenu, QToolButton + + +#@@CALIBRE_COMPAT_CODE@@ + + +def create_menu_action_unique(ia, parent_menu, menu_text, image=None, tooltip=None, + shortcut=None, triggered=None, is_checked=None, shortcut_name=None, + unique_name=None, favourites_menu_unique_name=None): + ''' + Create a menu action with the specified criteria and action, using the new + InterfaceAction.create_menu_action() function which ensures that regardless of + whether a shortcut is specified it will appear in Preferences->Keyboard + ''' + orig_shortcut = shortcut + kb = ia.gui.keyboard + if unique_name is None: + unique_name = menu_text + if not shortcut == False: + full_unique_name = menu_action_unique_name(ia, unique_name) + if full_unique_name in kb.shortcuts: + shortcut = False + else: + if shortcut is not None and not shortcut == False: + if len(shortcut) == 0: + shortcut = None + else: + shortcut = _(shortcut) + + if shortcut_name is None: + shortcut_name = menu_text.replace('&','') + + ac = ia.create_menu_action(parent_menu, unique_name, menu_text, icon=None, shortcut=shortcut, + description=tooltip, triggered=triggered, shortcut_name=shortcut_name) + if shortcut == False and not orig_shortcut == False: + if ac.calibre_shortcut_unique_name in ia.gui.keyboard.shortcuts: + kb.replace_action(ac.calibre_shortcut_unique_name, ac) + #if image: + #ac.setIcon(get_icons(image, "ACSM Input")) + + + return ac + +class ActualDeACSMGUIExtension(InterfaceAction): + name = "ACSM Input Plugin GUI Extension" + + popup_type = QToolButton.ToolButtonPopupMode.InstantPopup + action_type = 'global' + action_spec = ("ACSM Input", None, "ACSM Input Plugin by Leseratte10", None) + # Text, icon, tooltip, keyboard shortcut + + def genesis(self): + print("Genesis!") + self.menu = QMenu(self.gui) + + self.rebuild_menus() + + self.qaction.setMenu(self.menu) + icon = get_icons('acsm_logo_2.png', "ACSM Input Plugin") + self.qaction.setIcon(icon) + #self.qaction.triggered.connect(self.trigger_config_dialog) + + def rebuild_menus(self): + m = self.menu + m.clear() + + create_menu_action_unique(self, m, "ACSM Input configuration", None, shortcut=None, shortcut_name="Open ACSM Input plugin settings dialog", triggered=self.trigger_config_dialog) + create_menu_action_unique(self, m, "Show loaned books", None, shortcut=None, shortcut_name="ACSM: Open list of loaned books", triggered=self.trigger_loan_dialog) + + + def trigger_loan_dialog(self): + import calibre_plugins.deacsm.prefs as prefs + from calibre.gui2 import info_dialog + deacsmprefs = prefs.ACSMInput_Prefs() + + if (len(deacsmprefs["list_of_rented_books"]) == 0): + return info_dialog(None, "No loaned books", "You currently have no loaned books.", show=True, show_copy_button=False) + + from calibre_plugins.deacsm.config import RentedBooksDialog # type: ignore + d = RentedBooksDialog(self.gui) + d.exec_() + + + + + def trigger_config_dialog(self): + from calibre.customize.ui import _initialized_plugins + from calibre_plugins.deacsm.__init__ import PLUGIN_NAME + from calibre.gui2 import error_dialog + + plg = None + for plugin in _initialized_plugins: + if plugin.name == PLUGIN_NAME: + plg = plugin + break + + if plg is None: + msg = "Tried to open the ACSM Input plugin (DeACSM) settings, but I couldn't find the ACSM Input plugin. " + msg += "This is most likely a bug in the plugin. Try restarting Calibre, and if you still get this error, " + msg += "please open a bug report. " + return error_dialog(None, "Plugin not found", msg, show=True) + + plg.do_user_config(self.gui) + + diff --git a/calibre-plugin/gui_main_wrapper.py b/calibre-plugin/gui_main_wrapper.py new file mode 100644 index 0000000..0ebf149 --- /dev/null +++ b/calibre-plugin/gui_main_wrapper.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# GUI for the ACSM plugin. +# + +from calibre.customize import InterfaceActionBase # type: ignore +from calibre.customize import PluginInstallationType + + + +#@@CALIBRE_COMPAT_CODE@@ + +class DeACSMGUIExtension(InterfaceActionBase): + name = "ACSM Input Plugin GUI Extension" + description = "GUI code for ACSM Input Plugin (DeACSM). This is automatically installed and updated with the ACSM plugin." + supported_platforms = ['linux', 'osx', 'windows'] + author = "Leseratte10" + minimum_calibre_version = (4, 0, 0) + + can_be_disabled = False + # This plugin will be auto-loaded from the ACSM Input plugin. It doesn't make sense for the user + # to disable it. If necessary, the menu bar button can be removed through the Calibre settings. + + type = "File type" + # Just so that the GUI extension shows up at the same place as the actual ACSM Input plugin. + + installation_type = PluginInstallationType.EXTERNAL + # Mark this as user-installed so it shows up in the plugin list by default. + + actual_plugin = "calibre_plugins.deacsm.gui_main:ActualDeACSMGUIExtension" + + def is_customizable(self): + return False + + diff --git a/calibre-plugin/libadobeAccount.py b/calibre-plugin/libadobeAccount.py index 2549a25..e294582 100644 --- a/calibre-plugin/libadobeAccount.py +++ b/calibre-plugin/libadobeAccount.py @@ -787,7 +787,7 @@ def activateDevice(useVersionIndex = 0, proxyData = None): verbose_logging = False try: import calibre_plugins.deacsm.prefs as prefs - deacsmprefs = prefs.DeACSM_Prefs() + deacsmprefs = prefs.ACSMInput_Prefs() verbose_logging = deacsmprefs["detailed_logging"] except: pass diff --git a/calibre-plugin/libadobeFulfill.py b/calibre-plugin/libadobeFulfill.py index 93555bd..4610b4a 100644 --- a/calibre-plugin/libadobeFulfill.py +++ b/calibre-plugin/libadobeFulfill.py @@ -321,7 +321,7 @@ def fulfill(acsm_file, do_notify = False): verbose_logging = False try: import calibre_plugins.deacsm.prefs as prefs - deacsmprefs = prefs.DeACSM_Prefs() + deacsmprefs = prefs.ACSMInput_Prefs() verbose_logging = deacsmprefs["detailed_logging"] except: pass @@ -549,7 +549,7 @@ def updateLoanReturnData(fulfillmentResultToken, forceTestBehaviour=False): try: import calibre_plugins.deacsm.prefs as prefs # type: ignore - deacsmprefs = prefs.DeACSM_Prefs() + deacsmprefs = prefs.ACSMInput_Prefs() except: print("Exception while reading config file") return False @@ -563,7 +563,7 @@ def updateLoanReturnData(fulfillmentResultToken, forceTestBehaviour=False): done = False deacsmprefs["list_of_rented_books"].remove(book) break - + # Add all necessary information for a book return to the JSON array. # The config widget can then read this and present a list of not-yet-returned @@ -583,7 +583,7 @@ def tryReturnBook(bookData): verbose_logging = False try: import calibre_plugins.deacsm.prefs as prefs - deacsmprefs = prefs.DeACSM_Prefs() + deacsmprefs = prefs.ACSMInput_Prefs() verbose_logging = deacsmprefs["detailed_logging"] except: pass @@ -620,7 +620,7 @@ def tryReturnBook(bookData): etree.SubElement(full_text_xml, etree.QName(NSMAP["adept"], "signature")).text = signature - print("Would notify server %s:" % (operatorURL + "/LoanReturn")) + print("Notifying loan return server %s" % (operatorURL + "/LoanReturn")) doc_send = "\n" + etree.tostring(full_text_xml, encoding="utf-8", pretty_print=True, xml_declaration=False).decode("utf-8") if verbose_logging: print(doc_send) @@ -650,7 +650,7 @@ def performFulfillmentNotification(fulfillmentResultToken, forceOptional = False verbose_logging = False try: import calibre_plugins.deacsm.prefs as prefs - deacsmprefs = prefs.DeACSM_Prefs() + deacsmprefs = prefs.ACSMInput_Prefs() verbose_logging = deacsmprefs["detailed_logging"] except: pass diff --git a/calibre-plugin/prefs.py b/calibre-plugin/prefs.py index 8fe4815..c5746fd 100644 --- a/calibre-plugin/prefs.py +++ b/calibre-plugin/prefs.py @@ -8,12 +8,20 @@ import os import traceback from calibre.utils.config import JSONConfig, config_dir # type: ignore -from calibre_plugins.deacsm.__init__ import PLUGIN_NAME # type: ignore +from calibre.constants import iswindows # type: ignore -class DeACSM_Prefs(): +class ACSMInput_Prefs(): def __init__(self): - JSON_PATH = os.path.join("plugins", PLUGIN_NAME.strip().lower().replace(' ', '_') + '.json') + + JSON_PATH_OLD = os.path.join("plugins", "deacsm.json") + JSON_PATH = os.path.join("plugins", "ACSMInput", "ACSMInput.json") + + if os.path.exists(JSON_PATH_OLD) and not os.path.exists(JSON_PATH): + os.rename(JSON_PATH_OLD, JSON_PATH) + if not iswindows: + os.symlink(JSON_PATH_OLD, JSON_PATH) + self.deacsmprefs = JSONConfig(JSON_PATH) self.deacsmprefs.defaults['configured'] = False @@ -30,19 +38,15 @@ class DeACSM_Prefs(): self.pluginsdir = os.path.join(config_dir,"plugins") - if not os.path.exists(self.pluginsdir): - os.mkdir(self.pluginsdir) - self.maindir = os.path.join(self.pluginsdir,"DeACSM") - if not os.path.exists(self.maindir): - os.mkdir(self.maindir) + self.maindir = os.path.join(self.pluginsdir,"ACSMInput") self.accountdir = os.path.join(self.maindir,"account") if not os.path.exists(self.accountdir): - os.mkdir(self.accountdir) + raise Exception("Why does the account folder not exist?") - # Default to the builtin UA + # Default to the builtin account path self.deacsmprefs.defaults['path_to_account_data'] = self.accountdir - + def __getitem__(self,kind = None): if kind is not None: return self.deacsmprefs[kind]