diff --git a/manual/creating_plugins.rst b/manual/creating_plugins.rst index ca8fc285d4..4be39495d1 100644 --- a/manual/creating_plugins.rst +++ b/manual/creating_plugins.rst @@ -248,6 +248,30 @@ The container object and various useful utility functions that can be reused in your plugin code are documented in :ref:`polish_api`. +Running User Interface plugins in a separate process +----------------------------------------------------------- + +If you are writing a user interface plugin that needs to make use +of Qt WebEngine, it cannot be run in the main calibre process as it +is not possible to use WebEngine there. Instead you can copy the data +your plugin needs to a temporary directory and run the plugin with that +data in a separate process. A simple example plugin follows that shows how +to do this. + +You can download the plugin from +`webengine_demo_plugin.zip `_. + +The important part of the plugin is in two functions: + +.. literalinclude:: plugin_examples/webengine_demo/ui.py + :lines: 47- + + +The ``show_demo()`` function asks the user for a URL and then runs +the ``main()`` function passing it that URL. The ``main()`` function +displays the URL in a ``QWebEngineView``. + + Adding translations to your plugin -------------------------------------- diff --git a/manual/plugin_examples/webengine_demo/__init__.py b/manual/plugin_examples/webengine_demo/__init__.py new file mode 100644 index 0000000000..9f4fc31476 --- /dev/null +++ b/manual/plugin_examples/webengine_demo/__init__.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python2 +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +# License: GPLv3 Copyright: 2019, Kovid Goyal +from __future__ import absolute_import, division, print_function, unicode_literals + + +# The class that all Interface Action plugin wrappers must inherit from +from calibre.customize import InterfaceActionBase + + +class WebEginePluginDemo(InterfaceActionBase): + ''' + This class is a simple wrapper that provides information about the actual + plugin class. The actual interface plugin class is called InterfacePlugin + and is defined in the ui.py file, as specified in the actual_plugin field + below. + + The reason for having two classes is that it allows the command line + calibre utilities to run without needing to load the GUI libraries. + ''' + name = 'WebEngine Plugin Demo' + description = 'A WebEngine plugin demo' + supported_platforms = ['windows', 'osx', 'linux'] + author = 'Kovid Goyal' + version = (1, 0, 0) + minimum_calibre_version = (3, 99, 3) + + #: This field defines the GUI plugin class that contains all the code + #: that actually does something. Its format is module_path:class_name + #: The specified class must be defined in the specified module. + actual_plugin = 'calibre_plugins.webengine_demo.ui:InterfacePlugin' diff --git a/manual/plugin_examples/webengine_demo/images/icon.png b/manual/plugin_examples/webengine_demo/images/icon.png new file mode 100644 index 0000000000..7512b6ef07 Binary files /dev/null and b/manual/plugin_examples/webengine_demo/images/icon.png differ diff --git a/manual/plugin_examples/webengine_demo/main.py b/manual/plugin_examples/webengine_demo/main.py new file mode 100644 index 0000000000..300b85ad3f --- /dev/null +++ b/manual/plugin_examples/webengine_demo/main.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python2 +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import absolute_import, division, print_function, unicode_literals + +__license__ = 'GPL v3' +__copyright__ = '2011, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +if False: + # This is here to keep my python error checker from complaining about + # the builtin functions that will be defined by the plugin loading system + # You do not need this code in your plugins + get_icons = get_resources = None + +from PyQt5.Qt import QDialog, QVBoxLayout, QPushButton, QMessageBox, QLabel + +from calibre_plugins.interface_demo.config import prefs + + +class DemoDialog(QDialog): + + def __init__(self, gui, icon, do_user_config): + QDialog.__init__(self, gui) + self.gui = gui + self.do_user_config = do_user_config + + # The current database shown in the GUI + # db is an instance of the class LibraryDatabase from db/legacy.py + # This class has many, many methods that allow you to do a lot of + # things. For most purposes you should use db.new_api, which has + # a much nicer interface from db/cache.py + self.db = gui.current_db + + self.l = QVBoxLayout() + self.setLayout(self.l) + + self.label = QLabel(prefs['hello_world_msg']) + self.l.addWidget(self.label) + + self.setWindowTitle('Interface Plugin Demo') + self.setWindowIcon(icon) + + self.about_button = QPushButton('About', self) + self.about_button.clicked.connect(self.about) + self.l.addWidget(self.about_button) + + self.marked_button = QPushButton( + 'Show books with only one format in the calibre GUI', self) + self.marked_button.clicked.connect(self.marked) + self.l.addWidget(self.marked_button) + + self.view_button = QPushButton( + 'View the most recently added book', self) + self.view_button.clicked.connect(self.view) + self.l.addWidget(self.view_button) + + self.update_metadata_button = QPushButton( + 'Update metadata in a book\'s files', self) + self.update_metadata_button.clicked.connect(self.update_metadata) + self.l.addWidget(self.update_metadata_button) + + self.conf_button = QPushButton( + 'Configure this plugin', self) + self.conf_button.clicked.connect(self.config) + self.l.addWidget(self.conf_button) + + self.resize(self.sizeHint()) + + def about(self): + # Get the about text from a file inside the plugin zip file + # The get_resources function is a builtin function defined for all your + # plugin code. It loads files from the plugin zip file. It returns + # the bytes from the specified file. + # + # Note that if you are loading more than one file, for performance, you + # should pass a list of names to get_resources. In this case, + # get_resources will return a dictionary mapping names to bytes. Names that + # are not found in the zip file will not be in the returned dictionary. + text = get_resources('about.txt') + QMessageBox.about(self, 'About the Interface Plugin Demo', + text.decode('utf-8')) + + def marked(self): + ''' Show books with only one format ''' + db = self.db.new_api + matched_ids = {book_id for book_id in db.all_book_ids() if len(db.formats(book_id)) == 1} + # Mark the records with the matching ids + # new_api does not know anything about marked books, so we use the full + # db object + self.db.set_marked_ids(matched_ids) + + # Tell the GUI to search for all marked records + self.gui.search.setEditText('marked:true') + self.gui.search.do_search() + + def view(self): + ''' View the most recently added book ''' + most_recent = most_recent_id = None + db = self.db.new_api + for book_id, timestamp in db.all_field_for('timestamp', db.all_book_ids()).items(): + if most_recent is None or timestamp > most_recent: + most_recent = timestamp + most_recent_id = book_id + + if most_recent_id is not None: + # Get a reference to the View plugin + view_plugin = self.gui.iactions['View'] + # Ask the view plugin to launch the viewer for row_number + view_plugin._view_calibre_books([most_recent_id]) + + def update_metadata(self): + ''' + Set the metadata in the files in the selected book's record to + match the current metadata in the database. + ''' + from calibre.ebooks.metadata.meta import set_metadata + from calibre.gui2 import error_dialog, info_dialog + + # Get currently selected books + rows = self.gui.library_view.selectionModel().selectedRows() + if not rows or len(rows) == 0: + return error_dialog(self.gui, 'Cannot update metadata', + 'No books selected', show=True) + # Map the rows to book ids + ids = list(map(self.gui.library_view.model().id, rows)) + db = self.db.new_api + for book_id in ids: + # Get the current metadata for this book from the db + mi = db.get_metadata(book_id, get_cover=True, cover_as_data=True) + fmts = db.formats(book_id) + if not fmts: + continue + for fmt in fmts: + fmt = fmt.lower() + # Get a python file object for the format. This will be either + # an in memory file or a temporary on disk file + ffile = db.format(book_id, fmt, as_file=True) + ffile.seek(0) + # Set metadata in the format + set_metadata(ffile, mi, fmt) + ffile.seek(0) + # Now replace the file in the calibre library with the updated + # file. We dont use add_format_with_hooks as the hooks were + # already run when the file was first added to calibre. + db.add_format(book_id, fmt, ffile, run_hooks=False) + + info_dialog(self, 'Updated files', + 'Updated the metadata in the files of %d book(s)'%len(ids), + show=True) + + def config(self): + self.do_user_config(parent=self) + # Apply the changes + self.label.setText(prefs['hello_world_msg']) diff --git a/manual/plugin_examples/webengine_demo/plugin-import-name-webengine_demo.txt b/manual/plugin_examples/webengine_demo/plugin-import-name-webengine_demo.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/manual/plugin_examples/webengine_demo/ui.py b/manual/plugin_examples/webengine_demo/ui.py new file mode 100644 index 0000000000..41e105c936 --- /dev/null +++ b/manual/plugin_examples/webengine_demo/ui.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python2 +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import absolute_import, division, print_function, unicode_literals +# License: GPLv3 Copyright: 2019, Kovid Goyal + +if False: + # This is here to keep my python error checker from complaining about + # the builtin functions that will be defined by the plugin loading system + # You do not need this code in your plugins + get_icons = get_resources = None + +# The class that all interface action plugins must inherit from +from calibre.gui2.actions import InterfaceAction +from PyQt5.Qt import QInputDialog, QUrl + + +class InterfacePlugin(InterfaceAction): + + name = 'WebEngine Plugin Demo' + + # Declare the main action associated with this plugin + # The keyboard shortcut can be None if you dont want to use a keyboard + # shortcut. Remember that currently calibre has no central management for + # keyboard shortcuts, so try to use an unusual/unused shortcut. + action_spec = ('WebEngine Plugin Demo', None, + 'Run the WebEngine Plugin Demo', 'Ctrl+Shift+F2') + + def genesis(self): + # This method is called once per plugin, do initial setup here + + # Set the icon for this interface action + # The get_icons function is a builtin function defined for all your + # plugin code. It loads icons from the plugin zip file. It returns + # QIcon objects, if you want the actual data, use the analogous + # get_resources builtin function. + # + # Note that if you are loading more than one icon, for performance, you + # should pass a list of names to get_icons. In this case, get_icons + # will return a dictionary mapping names to QIcons. Names that + # are not found in the zip file will result in null QIcons. + icon = get_icons('images/icon.png') + + # The qaction is automatically created from the action_spec defined + # above + self.qaction.setIcon(icon) + self.qaction.triggered.connect(self.show_dialog) + + def show_dialog(self): + # Ask the user for a URL + url, ok = QInputDialog.getText(self.gui, 'Enter a URL', 'Enter a URL to browse below', text='https://calibre-ebook.com') + if not ok or not url: + return + # Launch a separate process to view the URL in WebEngine + self.gui.job_manager.launch_gui_app('webengine-dialog', kwargs={ + 'module':'calibre_plugins.webengine_demo.ui', 'url':url}) + + +def main(url): + # This function is run in a separate process and can do anything it likes, + # including use QWebEngine. Here it simply opens the passed in URL + # in a QWebEngineView + + # This import must happen before creating the Application() object + from PyQt5.QtWebEngineWidgets import QWebEngineView + + from calibre.gui2 import Application + app = Application([]) + w = QWebEngineView() + w.setUrl(QUrl(url)) + w.show() + w.raise_() + app.exec_() diff --git a/src/calibre/gui_launch.py b/src/calibre/gui_launch.py index 3d8f98303d..093a1f7b7f 100644 --- a/src/calibre/gui_launch.py +++ b/src/calibre/gui_launch.py @@ -88,6 +88,16 @@ def store_dialog(args=sys.argv): main(args) +def webengine_dialog(**kw): + detach_gui() + init_dbus() + from calibre.debug import load_user_plugins + load_user_plugins() + import importlib + m = importlib.import_module(kw.pop('module')) + getattr(m, kw.pop('entry_func', 'main'))(**kw) + + def toc_dialog(**kw): detach_gui() init_dbus() diff --git a/src/calibre/utils/ipc/worker.py b/src/calibre/utils/ipc/worker.py index f55ac3c951..42e4275beb 100644 --- a/src/calibre/utils/ipc/worker.py +++ b/src/calibre/utils/ipc/worker.py @@ -35,6 +35,9 @@ PARALLEL_FUNCS = { 'toc-dialog' : ('calibre.gui_launch', 'toc_dialog', None), + 'webengine-dialog' : + ('calibre.gui_launch', 'webengine_dialog', None), + 'render_pages' : ('calibre.ebooks.comic.input', 'render_pages', 'notification'),