1__license__   = 'GPL v3'
2__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
3
4import os, sys, zipfile, importlib, enum
5
6from calibre.constants import numeric_version, iswindows, ismacos
7from calibre.ptempfile import PersistentTemporaryFile
8
9if iswindows:
10    platform = 'windows'
11elif ismacos:
12    platform = 'osx'
13else:
14    platform = 'linux'
15
16
17class PluginNotFound(ValueError):
18    pass
19
20
21class InvalidPlugin(ValueError):
22    pass
23
24
25class PluginInstallationType(enum.IntEnum):
26    EXTERNAL = 1
27    SYSTEM = 2
28    BUILTIN = 3
29
30
31class Plugin:  # {{{
32    '''
33    A calibre plugin. Useful members include:
34
35       * ``self.installation_type``: Stores how the plugin was installed.
36       * ``self.plugin_path``: Stores path to the ZIP file that contains
37                               this plugin or None if it is a builtin
38                               plugin
39       * ``self.site_customization``: Stores a customization string entered
40                                      by the user.
41
42    Methods that should be overridden in sub classes:
43
44       * :meth:`initialize`
45       * :meth:`customization_help`
46
47    Useful methods:
48
49        * :meth:`temporary_file`
50        * :meth:`__enter__`
51        * :meth:`load_resources`
52
53    '''
54    #: List of platforms this plugin works on.
55    #: For example: ``['windows', 'osx', 'linux']``
56    supported_platforms = []
57
58    #: The name of this plugin. You must set it something other
59    #: than Trivial Plugin for it to work.
60    name           = 'Trivial Plugin'
61
62    #: The version of this plugin as a 3-tuple (major, minor, revision)
63    version        = (1, 0, 0)
64
65    #: A short string describing what this plugin does
66    description    = _('Does absolutely nothing')
67
68    #: The author of this plugin
69    author         = _('Unknown')
70
71    #: When more than one plugin exists for a filetype,
72    #: the plugins are run in order of decreasing priority.
73    #: Plugins with higher priority will be run first.
74    #: The highest possible priority is ``sys.maxsize``.
75    #: Default priority is 1.
76    priority = 1
77
78    #: The earliest version of calibre this plugin requires
79    minimum_calibre_version = (0, 4, 118)
80
81    #: The way this plugin is installed
82    installation_type  = None
83
84    #: If False, the user will not be able to disable this plugin. Use with
85    #: care.
86    can_be_disabled = True
87
88    #: The type of this plugin. Used for categorizing plugins in the
89    #: GUI
90    type = _('Base')
91
92    def __init__(self, plugin_path):
93        self.plugin_path        = plugin_path
94        self.site_customization = None
95
96    def initialize(self):
97        '''
98        Called once when calibre plugins are initialized.  Plugins are
99        re-initialized every time a new plugin is added. Also note that if the
100        plugin is run in a worker process, such as for adding books, then the
101        plugin will be initialized for every new worker process.
102
103        Perform any plugin specific initialization here, such as extracting
104        resources from the plugin ZIP file. The path to the ZIP file is
105        available as ``self.plugin_path``.
106
107        Note that ``self.site_customization`` is **not** available at this point.
108        '''
109        pass
110
111    def config_widget(self):
112        '''
113        Implement this method and :meth:`save_settings` in your plugin to
114        use a custom configuration dialog, rather then relying on the simple
115        string based default customization.
116
117        This method, if implemented, must return a QWidget. The widget can have
118        an optional method validate() that takes no arguments and is called
119        immediately after the user clicks OK. Changes are applied if and only
120        if the method returns True.
121
122        If for some reason you cannot perform the configuration at this time,
123        return a tuple of two strings (message, details), these will be
124        displayed as a warning dialog to the user and the process will be
125        aborted.
126        '''
127        raise NotImplementedError()
128
129    def save_settings(self, config_widget):
130        '''
131        Save the settings specified by the user with config_widget.
132
133        :param config_widget: The widget returned by :meth:`config_widget`.
134
135        '''
136        raise NotImplementedError()
137
138    def do_user_config(self, parent=None):
139        '''
140        This method shows a configuration dialog for this plugin. It returns
141        True if the user clicks OK, False otherwise. The changes are
142        automatically applied.
143        '''
144        from qt.core import QDialog, QDialogButtonBox, QVBoxLayout, \
145                QLabel, Qt, QLineEdit
146        from calibre.gui2 import gprefs
147
148        prefname = 'plugin config dialog:'+self.type + ':' + self.name
149        geom = gprefs.get(prefname, None)
150
151        config_dialog = QDialog(parent)
152        button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
153        v = QVBoxLayout(config_dialog)
154
155        def size_dialog():
156            if geom is None:
157                config_dialog.resize(config_dialog.sizeHint())
158            else:
159                from qt.core import QApplication
160                QApplication.instance().safe_restore_geometry(config_dialog, geom)
161
162        button_box.accepted.connect(config_dialog.accept)
163        button_box.rejected.connect(config_dialog.reject)
164        config_dialog.setWindowTitle(_('Customize') + ' ' + self.name)
165        try:
166            config_widget = self.config_widget()
167        except NotImplementedError:
168            config_widget = None
169
170        if isinstance(config_widget, tuple):
171            from calibre.gui2 import warning_dialog
172            warning_dialog(parent, _('Cannot configure'), config_widget[0],
173                    det_msg=config_widget[1], show=True)
174            return False
175
176        if config_widget is not None:
177            v.addWidget(config_widget)
178            v.addWidget(button_box)
179            size_dialog()
180            config_dialog.exec()
181
182            if config_dialog.result() == QDialog.DialogCode.Accepted:
183                if hasattr(config_widget, 'validate'):
184                    if config_widget.validate():
185                        self.save_settings(config_widget)
186                else:
187                    self.save_settings(config_widget)
188        else:
189            from calibre.customize.ui import plugin_customization, \
190                customize_plugin
191            help_text = self.customization_help(gui=True)
192            help_text = QLabel(help_text, config_dialog)
193            help_text.setWordWrap(True)
194            help_text.setTextInteractionFlags(Qt.TextInteractionFlag.LinksAccessibleByMouse | Qt.TextInteractionFlag.LinksAccessibleByKeyboard)
195            help_text.setOpenExternalLinks(True)
196            v.addWidget(help_text)
197            sc = plugin_customization(self)
198            if not sc:
199                sc = ''
200            sc = sc.strip()
201            sc = QLineEdit(sc, config_dialog)
202            v.addWidget(sc)
203            v.addWidget(button_box)
204            size_dialog()
205            config_dialog.exec()
206
207            if config_dialog.result() == QDialog.DialogCode.Accepted:
208                sc = str(sc.text()).strip()
209                customize_plugin(self, sc)
210
211        geom = bytearray(config_dialog.saveGeometry())
212        gprefs[prefname] = geom
213
214        return config_dialog.result()
215
216    def load_resources(self, names):
217        '''
218        If this plugin comes in a ZIP file (user added plugin), this method
219        will allow you to load resources from the ZIP file.
220
221        For example to load an image::
222
223            pixmap = QPixmap()
224            pixmap.loadFromData(self.load_resources(['images/icon.png'])['images/icon.png'])
225            icon = QIcon(pixmap)
226
227        :param names: List of paths to resources in the ZIP file using / as separator
228
229        :return: A dictionary of the form ``{name: file_contents}``. Any names
230                 that were not found in the ZIP file will not be present in the
231                 dictionary.
232
233        '''
234        if self.plugin_path is None:
235            raise ValueError('This plugin was not loaded from a ZIP file')
236        ans = {}
237        with zipfile.ZipFile(self.plugin_path, 'r') as zf:
238            for candidate in zf.namelist():
239                if candidate in names:
240                    ans[candidate] = zf.read(candidate)
241        return ans
242
243    def customization_help(self, gui=False):
244        '''
245        Return a string giving help on how to customize this plugin.
246        By default raise a :class:`NotImplementedError`, which indicates that
247        the plugin does not require customization.
248
249        If you re-implement this method in your subclass, the user will
250        be asked to enter a string as customization for this plugin.
251        The customization string will be available as
252        ``self.site_customization``.
253
254        Site customization could be anything, for example, the path to
255        a needed binary on the user's computer.
256
257        :param gui: If True return HTML help, otherwise return plain text help.
258
259        '''
260        raise NotImplementedError()
261
262    def temporary_file(self, suffix):
263        '''
264        Return a file-like object that is a temporary file on the file system.
265        This file will remain available even after being closed and will only
266        be removed on interpreter shutdown. Use the ``name`` member of the
267        returned object to access the full path to the created temporary file.
268
269        :param suffix: The suffix that the temporary file will have.
270        '''
271        return PersistentTemporaryFile(suffix)
272
273    def is_customizable(self):
274        try:
275            self.customization_help()
276            return True
277        except NotImplementedError:
278            return False
279
280    def __enter__(self, *args):
281        '''
282        Add this plugin to the python path so that it's contents become directly importable.
283        Useful when bundling large python libraries into the plugin. Use it like this::
284            with plugin:
285                import something
286        '''
287        if self.plugin_path is not None:
288            from calibre.utils.zipfile import ZipFile
289            from importlib.machinery import EXTENSION_SUFFIXES
290            with ZipFile(self.plugin_path) as zf:
291                extensions = {x.lower() for x in EXTENSION_SUFFIXES}
292                zip_safe = True
293                for name in zf.namelist():
294                    for q in extensions:
295                        if name.endswith(q):
296                            zip_safe = False
297                            break
298                    if not zip_safe:
299                        break
300                if zip_safe:
301                    sys.path.append(self.plugin_path)
302                    self.sys_insertion_path = self.plugin_path
303                else:
304                    from calibre.ptempfile import TemporaryDirectory
305                    self._sys_insertion_tdir = TemporaryDirectory('plugin_unzip')
306                    self.sys_insertion_path = self._sys_insertion_tdir.__enter__(*args)
307                    zf.extractall(self.sys_insertion_path)
308                    sys.path.append(self.sys_insertion_path)
309
310    def __exit__(self, *args):
311        ip, it = getattr(self, 'sys_insertion_path', None), getattr(self,
312                '_sys_insertion_tdir', None)
313        if ip in sys.path:
314            sys.path.remove(ip)
315        if hasattr(it, '__exit__'):
316            it.__exit__(*args)
317
318    def cli_main(self, args):
319        '''
320        This method is the main entry point for your plugins command line
321        interface. It is called when the user does: calibre-debug -r "Plugin
322        Name". Any arguments passed are present in the args variable.
323        '''
324        raise NotImplementedError('The %s plugin has no command line interface'
325                                  %self.name)
326
327# }}}
328
329
330class FileTypePlugin(Plugin):  # {{{
331    '''
332    A plugin that is associated with a particular set of file types.
333    '''
334
335    #: Set of file types for which this plugin should be run.
336    #: Use '*' for all file types.
337    #: For example: ``{'lit', 'mobi', 'prc'}``
338    file_types     = set()
339
340    #: If True, this plugin is run when books are added
341    #: to the database
342    on_import      = False
343
344    #: If True, this plugin is run after books are added
345    #: to the database. In this case the postimport and postadd
346    #: methods of the plugin are called.
347    on_postimport  = False
348
349    #: If True, this plugin is run just before a conversion
350    on_preprocess  = False
351
352    #: If True, this plugin is run after conversion
353    #: on the final file produced by the conversion output plugin.
354    on_postprocess = False
355
356    type = _('File type')
357
358    def run(self, path_to_ebook):
359        '''
360        Run the plugin. Must be implemented in subclasses.
361        It should perform whatever modifications are required
362        on the e-book and return the absolute path to the
363        modified e-book. If no modifications are needed, it should
364        return the path to the original e-book. If an error is encountered
365        it should raise an Exception. The default implementation
366        simply return the path to the original e-book. Note that the path to
367        the original file (before any file type plugins are run, is available as
368        self.original_path_to_file).
369
370        The modified e-book file should be created with the
371        :meth:`temporary_file` method.
372
373        :param path_to_ebook: Absolute path to the e-book.
374
375        :return: Absolute path to the modified e-book.
376        '''
377        # Default implementation does nothing
378        return path_to_ebook
379
380    def postimport(self, book_id, book_format, db):
381        '''
382        Called post import, i.e., after the book file has been added to the database. Note that
383        this is different from :meth:`postadd` which is called when the book record is created for
384        the first time. This method is called whenever a new file is added to a book record. It is
385        useful for modifying the book record based on the contents of the newly added file.
386
387        :param book_id: Database id of the added book.
388        :param book_format: The file type of the book that was added.
389        :param db: Library database.
390        '''
391        pass  # Default implementation does nothing
392
393    def postadd(self, book_id, fmt_map, db):
394        '''
395        Called post add, i.e. after a book has been added to the db. Note that
396        this is different from :meth:`postimport`, which is called after a single book file
397        has been added to a book. postadd() is called only when an entire book record
398        with possibly more than one book file has been created for the first time.
399        This is useful if you wish to modify the book record in the database when the
400        book is first added to calibre.
401
402        :param book_id: Database id of the added book.
403        :param fmt_map: Map of file format to path from which the file format
404            was added. Note that this might or might not point to an actual
405            existing file, as sometimes files are added as streams. In which case
406            it might be a dummy value or a non-existent path.
407        :param db: Library database
408        '''
409        pass  # Default implementation does nothing
410
411# }}}
412
413
414class MetadataReaderPlugin(Plugin):  # {{{
415    '''
416    A plugin that implements reading metadata from a set of file types.
417    '''
418    #: Set of file types for which this plugin should be run.
419    #: For example: ``set(['lit', 'mobi', 'prc'])``
420    file_types     = set()
421
422    supported_platforms = ['windows', 'osx', 'linux']
423    version = numeric_version
424    author  = 'Kovid Goyal'
425
426    type = _('Metadata reader')
427
428    def __init__(self, *args, **kwargs):
429        Plugin.__init__(self, *args, **kwargs)
430        self.quick = False
431
432    def get_metadata(self, stream, type):
433        '''
434        Return metadata for the file represented by stream (a file like object
435        that supports reading). Raise an exception when there is an error
436        with the input data.
437
438        :param type: The type of file. Guaranteed to be one of the entries
439            in :attr:`file_types`.
440        :return: A :class:`calibre.ebooks.metadata.book.Metadata` object
441        '''
442        return None
443# }}}
444
445
446class MetadataWriterPlugin(Plugin):  # {{{
447    '''
448    A plugin that implements reading metadata from a set of file types.
449    '''
450    #: Set of file types for which this plugin should be run.
451    #: For example: ``set(['lit', 'mobi', 'prc'])``
452    file_types     = set()
453
454    supported_platforms = ['windows', 'osx', 'linux']
455    version = numeric_version
456    author  = 'Kovid Goyal'
457
458    type = _('Metadata writer')
459
460    def __init__(self, *args, **kwargs):
461        Plugin.__init__(self, *args, **kwargs)
462        self.apply_null = False
463
464    def set_metadata(self, stream, mi, type):
465        '''
466        Set metadata for the file represented by stream (a file like object
467        that supports reading). Raise an exception when there is an error
468        with the input data.
469
470        :param type: The type of file. Guaranteed to be one of the entries
471            in :attr:`file_types`.
472        :param mi: A :class:`calibre.ebooks.metadata.book.Metadata` object
473        '''
474        pass
475
476# }}}
477
478
479class CatalogPlugin(Plugin):  # {{{
480    '''
481    A plugin that implements a catalog generator.
482    '''
483
484    resources_path = None
485
486    #: Output file type for which this plugin should be run.
487    #: For example: 'epub' or 'xml'
488    file_types = set()
489
490    type = _('Catalog generator')
491
492    #: CLI parser options specific to this plugin, declared as `namedtuple` `Option`:
493    #:
494    #:     from collections import namedtuple
495    #:     Option = namedtuple('Option', 'option, default, dest, help')
496    #:     cli_options = [Option('--catalog-title', default = 'My Catalog',
497    #:     dest = 'catalog_title', help = (_('Title of generated catalog. \nDefault:') + " '" + '%default' + "'"))]
498    #:     cli_options parsed in calibre.db.cli.cmd_catalog:option_parser()
499    #:
500    cli_options = []
501
502    def _field_sorter(self, key):
503        '''
504        Custom fields sort after standard fields
505        '''
506        if key.startswith('#'):
507            return '~%s' % key[1:]
508        else:
509            return key
510
511    def search_sort_db(self, db, opts):
512
513        db.search(opts.search_text)
514
515        if opts.sort_by:
516            # 2nd arg = ascending
517            db.sort(opts.sort_by, True)
518        return db.get_data_as_dict(ids=opts.ids)
519
520    def get_output_fields(self, db, opts):
521        # Return a list of requested fields
522        all_std_fields = {'author_sort','authors','comments','cover','formats',
523                           'id','isbn','library_name','ondevice','pubdate','publisher',
524                           'rating','series_index','series','size','tags','timestamp',
525                           'title_sort','title','uuid','languages','identifiers'}
526        all_custom_fields = set(db.custom_field_keys())
527        for field in list(all_custom_fields):
528            fm = db.field_metadata[field]
529            if fm['datatype'] == 'series':
530                all_custom_fields.add(field+'_index')
531        all_fields = all_std_fields.union(all_custom_fields)
532
533        if opts.fields != 'all':
534            # Make a list from opts.fields
535            of = [x.strip() for x in opts.fields.split(',')]
536            requested_fields = set(of)
537
538            # Validate requested_fields
539            if requested_fields - all_fields:
540                from calibre.library import current_library_name
541                invalid_fields = sorted(list(requested_fields - all_fields))
542                print("invalid --fields specified: %s" % ', '.join(invalid_fields))
543                print("available fields in '%s': %s" %
544                      (current_library_name(), ', '.join(sorted(list(all_fields)))))
545                raise ValueError("unable to generate catalog with specified fields")
546
547            fields = [x for x in of if x in all_fields]
548        else:
549            fields = sorted(all_fields, key=self._field_sorter)
550
551        if not opts.connected_device['is_device_connected'] and 'ondevice' in fields:
552            fields.pop(int(fields.index('ondevice')))
553
554        return fields
555
556    def initialize(self):
557        '''
558        If plugin is not a built-in, copy the plugin's .ui and .py files from
559        the ZIP file to $TMPDIR.
560        Tab will be dynamically generated and added to the Catalog Options dialog in
561        calibre.gui2.dialogs.catalog.py:Catalog
562        '''
563        from calibre.customize.builtins import plugins as builtin_plugins
564        from calibre.customize.ui import config
565        from calibre.ptempfile import PersistentTemporaryDirectory
566
567        if not type(self) in builtin_plugins and self.name not in config['disabled_plugins']:
568            files_to_copy = ["%s.%s" % (self.name.lower(),ext) for ext in ["ui","py"]]
569            resources = zipfile.ZipFile(self.plugin_path,'r')
570
571            if self.resources_path is None:
572                self.resources_path = PersistentTemporaryDirectory('_plugin_resources', prefix='')
573
574            for file in files_to_copy:
575                try:
576                    resources.extract(file, self.resources_path)
577                except:
578                    print(" customize:__init__.initialize(): %s not found in %s" % (file, os.path.basename(self.plugin_path)))
579                    continue
580            resources.close()
581
582    def run(self, path_to_output, opts, db, ids, notification=None):
583        '''
584        Run the plugin. Must be implemented in subclasses.
585        It should generate the catalog in the format specified
586        in file_types, returning the absolute path to the
587        generated catalog file. If an error is encountered
588        it should raise an Exception.
589
590        The generated catalog file should be created with the
591        :meth:`temporary_file` method.
592
593        :param path_to_output: Absolute path to the generated catalog file.
594        :param opts: A dictionary of keyword arguments
595        :param db: A LibraryDatabase2 object
596        '''
597        # Default implementation does nothing
598        raise NotImplementedError('CatalogPlugin.generate_catalog() default '
599                'method, should be overridden in subclass')
600
601# }}}
602
603
604class InterfaceActionBase(Plugin):  # {{{
605
606    supported_platforms = ['windows', 'osx', 'linux']
607    author         = 'Kovid Goyal'
608    type = _('User interface action')
609    can_be_disabled = False
610
611    actual_plugin = None
612
613    def __init__(self, *args, **kwargs):
614        Plugin.__init__(self, *args, **kwargs)
615        self.actual_plugin_ = None
616
617    def load_actual_plugin(self, gui):
618        '''
619        This method must return the actual interface action plugin object.
620        '''
621        ac = self.actual_plugin_
622        if ac is None:
623            mod, cls = self.actual_plugin.split(':')
624            ac = getattr(importlib.import_module(mod), cls)(gui,
625                    self.site_customization)
626            self.actual_plugin_ = ac
627        return ac
628
629# }}}
630
631
632class PreferencesPlugin(Plugin):  # {{{
633
634    '''
635    A plugin representing a widget displayed in the Preferences dialog.
636
637    This plugin has only one important method :meth:`create_widget`. The
638    various fields of the plugin control how it is categorized in the UI.
639    '''
640
641    supported_platforms = ['windows', 'osx', 'linux']
642    author         = 'Kovid Goyal'
643    type = _('Preferences')
644    can_be_disabled = False
645
646    #: Import path to module that contains a class named ConfigWidget
647    #: which implements the ConfigWidgetInterface. Used by
648    #: :meth:`create_widget`.
649    config_widget = None
650
651    #: Where in the list of categories the :attr:`category` of this plugin should be.
652    category_order = 100
653
654    #: Where in the list of names in a category, the :attr:`gui_name` of this
655    #: plugin should be
656    name_order = 100
657
658    #: The category this plugin should be in
659    category = None
660
661    #: The category name displayed to the user for this plugin
662    gui_category = None
663
664    #: The name displayed to the user for this plugin
665    gui_name = None
666
667    #: The icon for this plugin, should be an absolute path
668    icon = None
669
670    #: The description used for tooltips and the like
671    description = None
672
673    def create_widget(self, parent=None):
674        '''
675        Create and return the actual Qt widget used for setting this group of
676        preferences. The widget must implement the
677        :class:`calibre.gui2.preferences.ConfigWidgetInterface`.
678
679        The default implementation uses :attr:`config_widget` to instantiate
680        the widget.
681        '''
682        base, _, wc = self.config_widget.partition(':')
683        if not wc:
684            wc = 'ConfigWidget'
685        base = importlib.import_module(base)
686        widget = getattr(base, wc)
687        return widget(parent)
688
689# }}}
690
691
692class StoreBase(Plugin):  # {{{
693
694    supported_platforms = ['windows', 'osx', 'linux']
695    author         = 'John Schember'
696    type = _('Store')
697    # Information about the store. Should be in the primary language
698    # of the store. This should not be translatable when set by
699    # a subclass.
700    description = _('An e-book store.')
701    minimum_calibre_version = (0, 8, 0)
702    version        = (1, 0, 1)
703
704    actual_plugin = None
705
706    # Does the store only distribute e-books without DRM.
707    drm_free_only = False
708    # This is the 2 letter country code for the corporate
709    # headquarters of the store.
710    headquarters = ''
711    # All formats the store distributes e-books in.
712    formats = []
713    # Is this store on an affiliate program?
714    affiliate = False
715
716    def load_actual_plugin(self, gui):
717        '''
718        This method must return the actual interface action plugin object.
719        '''
720        mod, cls = self.actual_plugin.split(':')
721        self.actual_plugin_object  = getattr(importlib.import_module(mod), cls)(gui, self.name)
722        return self.actual_plugin_object
723
724    def customization_help(self, gui=False):
725        if getattr(self, 'actual_plugin_object', None) is not None:
726            return self.actual_plugin_object.customization_help(gui)
727        raise NotImplementedError()
728
729    def config_widget(self):
730        if getattr(self, 'actual_plugin_object', None) is not None:
731            return self.actual_plugin_object.config_widget()
732        raise NotImplementedError()
733
734    def save_settings(self, config_widget):
735        if getattr(self, 'actual_plugin_object', None) is not None:
736            return self.actual_plugin_object.save_settings(config_widget)
737        raise NotImplementedError()
738
739# }}}
740
741
742class EditBookToolPlugin(Plugin):  # {{{
743
744    type = _('Edit book tool')
745    minimum_calibre_version = (1, 46, 0)
746
747# }}}
748
749
750class LibraryClosedPlugin(Plugin):  # {{{
751    '''
752    LibraryClosedPlugins are run when a library is closed, either at shutdown,
753    when the library is changed, or when a library is used in some other way.
754    At the moment these plugins won't be called by the CLI functions.
755    '''
756    type = _('Library closed')
757
758    # minimum version 2.54 because that is when support was added
759    minimum_calibre_version = (2, 54, 0)
760
761    def run(self, db):
762        '''
763        The db will be a reference to the new_api (db.cache.py).
764
765        The plugin must run to completion. It must not use the GUI, threads, or
766        any signals.
767        '''
768        raise NotImplementedError('LibraryClosedPlugin '
769                'run method must be overridden in subclass')
770# }}}
771