From 660fa430f840765bd24ee85c93a6d323640f2cf6 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 23 Dec 2008 20:13:25 -0800 Subject: [PATCH] Add support for file type plugins to calibre, so that 3rd party developers can customize calibre. --- src/calibre/constants.py | 2 +- src/calibre/customize/__init__.py | 18 +++-- src/calibre/customize/ui.py | 31 ++++++-- src/calibre/ebooks/epub/from_any.py | 2 + src/calibre/ebooks/epub/from_html.py | 2 + src/calibre/ebooks/lit/writer.py | 2 + src/calibre/ebooks/lrf/any/convert_from.py | 4 + src/calibre/ebooks/lrf/comic/convert_from.py | 3 + src/calibre/ebooks/lrf/html/convert_from.py | 2 + src/calibre/gui2/dialogs/config.py | 80 ++++++++++++++++++-- src/calibre/gui2/dialogs/config.ui | 4 + src/calibre/gui2/dialogs/metadata_single.py | 6 +- src/calibre/library/database2.py | 2 + 13 files changed, 134 insertions(+), 24 deletions(-) diff --git a/src/calibre/constants.py b/src/calibre/constants.py index 01f77f9cfb..466d0b11b1 100644 --- a/src/calibre/constants.py +++ b/src/calibre/constants.py @@ -2,7 +2,7 @@ __license__ = 'GPL v3' __copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net' __docformat__ = 'restructuredtext en' __appname__ = 'calibre' -__version__ = '0.4.117' +__version__ = '0.4.118' __author__ = "Kovid Goyal " ''' Various run time constants. diff --git a/src/calibre/customize/__init__.py b/src/calibre/customize/__init__.py index d5c10b64de..ca8aba6031 100644 --- a/src/calibre/customize/__init__.py +++ b/src/calibre/customize/__init__.py @@ -52,6 +52,12 @@ class Plugin(object): #: The earliest version of calibre this plugin requires minimum_calibre_version = (0, 4, 118) + #: If False, the user will not be able to disable this plugin. Use with + #: care. + can_be_disabled = True + + #: The type of this plugin. Used for categorizing plugins in the + #: GUI type = _('Base') def __init__(self, plugin_path): @@ -71,8 +77,7 @@ class Plugin(object): ''' pass - @classmethod - def customization_help(cls): + def customization_help(self, gui=False): ''' Return a string giving help on how to customize this plugin. By default raise a :class:`NotImplementedError`, which indicates that @@ -84,7 +89,9 @@ class Plugin(object): ``self.site_customization``. Site customization could be anything, for example, the path to - a needed binary on the user's computer. + a needed binary on the user's computer. + + :param gui: If True return HTML help, otherwise return plain text help. ''' raise NotImplementedError @@ -99,10 +106,9 @@ class Plugin(object): ''' return PersistentTemporaryFile(suffix) - @classmethod - def is_customizable(cls): + def is_customizable(self): try: - cls.customization_help() + self.customization_help() return True except NotImplementedError: return False diff --git a/src/calibre/customize/ui.py b/src/calibre/customize/ui.py index 560fe06cff..031d24aecb 100644 --- a/src/calibre/customize/ui.py +++ b/src/calibre/customize/ui.py @@ -92,10 +92,12 @@ def reread_filetype_plugins(): _on_postprocess[ft].append(plugin) -def _run_filetype_plugins(path_to_file, ft, occasion='preprocess'): +def _run_filetype_plugins(path_to_file, ft=None, occasion='preprocess'): occasion = {'import':_on_import, 'preprocess':_on_preprocess, 'postprocess':_on_postprocess}[occasion] customization = config['plugin_customization'] + if ft is None: + ft = os.path.splitext(path_to_file)[-1].lower().replace('.', '') nfp = path_to_file for plugin in occasion.get(ft, []): if is_disabled(plugin): @@ -107,6 +109,10 @@ def _run_filetype_plugins(path_to_file, ft, occasion='preprocess'): except: print 'Running file type plugin %s failed with traceback:'%plugin.name traceback.print_exc() + x = lambda j : os.path.normpath(os.path.normcase(j)) + if occasion == 'postprocess' and x(nfp) != x(path_to_file): + shutil.copyfile(nfp, path_to_file) + nfp = path_to_file return nfp run_plugins_on_import = functools.partial(_run_filetype_plugins, @@ -119,7 +125,7 @@ run_plugins_on_postprocess = functools.partial(_run_filetype_plugins, def initialize_plugin(plugin, path_to_zip_file): try: - plugin(path_to_zip_file) + return plugin(path_to_zip_file) except Exception: print 'Failed to initialize plugin:', plugin.name, plugin.version tb = traceback.format_exc() @@ -130,9 +136,9 @@ def initialize_plugin(plugin, path_to_zip_file): def add_plugin(path_to_zip_file): make_config_dir() plugin = load_plugin(path_to_zip_file) - initialize_plugin(plugin, path_to_zip_file) + plugin = initialize_plugin(plugin, path_to_zip_file) plugins = config['plugins'] - zfp = os.path.join(plugin_dir, 'name.zip') + zfp = os.path.join(plugin_dir, plugin.name+'.zip') if os.path.exists(zfp): os.remove(zfp) shutil.copyfile(path_to_zip_file, zfp) @@ -151,6 +157,9 @@ def find_plugin(name): def disable_plugin(plugin_or_name): x = getattr(plugin_or_name, 'name', plugin_or_name) + plugin = find_plugin(x) + if not plugin.can_be_disabled: + raise ValueError('Plugin %s cannot be disabled'%x) dp = config['disabled_plugins'] dp.add(x) config['disabled_plugins'] = dp @@ -168,7 +177,7 @@ def initialize_plugins(): for zfp in list(config['plugins'].values()) + builtin_plugins: try: plugin = load_plugin(zfp) if not isinstance(zfp, type) else zfp - initialize_plugin(plugin, zfp if not isinstance(zfp, type) else zfp) + plugin = initialize_plugin(plugin, zfp if not isinstance(zfp, type) else zfp) _initialized_plugins.append(plugin) except: print 'Failed to initialize plugin...' @@ -199,6 +208,14 @@ def option_parser(): def initialized_plugins(): return _initialized_plugins +def customize_plugin(plugin, custom): + d = config['plugin_customization'] + d[plugin.name] = custom.strip() + config['plugin_customization'] = d + +def plugin_customization(plugin): + return config['plugin_customization'].get(plugin.name, '') + def main(args=sys.argv): parser = option_parser() if len(args) < 2: @@ -214,7 +231,7 @@ def main(args=sys.argv): if plugin is None: print 'No plugin with the name %s exists'%name return 1 - config['plugin_customization'][plugin.name] = custom.strip() + customize_plugin(plugin, custom) if opts.enable_plugin is not None: enable_plugin(opts.enable_plugin.strip()) if opts.disable_plugin is not None: @@ -227,7 +244,7 @@ def main(args=sys.argv): print fmt%( plugin.type, plugin.name, plugin.version, is_disabled(plugin), - config['plugin_customization'].get(plugin.name, '') + plugin_customization(plugin) ) print '\t', plugin.description if plugin.is_customizable(): diff --git a/src/calibre/ebooks/epub/from_any.py b/src/calibre/ebooks/epub/from_any.py index a6fae55ec5..e204b38b03 100644 --- a/src/calibre/ebooks/epub/from_any.py +++ b/src/calibre/ebooks/epub/from_any.py @@ -18,6 +18,7 @@ from calibre.ptempfile import TemporaryDirectory from calibre.ebooks.metadata import MetaInformation from calibre.ebooks.metadata.opf2 import OPFCreator from calibre.utils.zipfile import ZipFile +from calibre.customize.ui import run_plugins_on_preprocess def lit2opf(path, tdir, opts): from calibre.ebooks.lit.reader import LitReader @@ -118,6 +119,7 @@ def unarchive(path, tdir): def any2epub(opts, path, notification=None, create_epub=True, oeb_cover=False, extract_to=None): + path = run_plugins_on_preprocess(path) ext = os.path.splitext(path)[1] if not ext: raise ValueError('Unknown file type: '+path) diff --git a/src/calibre/ebooks/epub/from_html.py b/src/calibre/ebooks/epub/from_html.py index d62bb936b2..8ebd426a14 100644 --- a/src/calibre/ebooks/epub/from_html.py +++ b/src/calibre/ebooks/epub/from_html.py @@ -48,6 +48,7 @@ from calibre.ebooks.epub import initialize_container, PROFILES from calibre.ebooks.epub.split import split from calibre.ebooks.epub.fonts import Rationalizer from calibre.constants import preferred_encoding +from calibre.customize.ui import run_plugins_on_postprocess from calibre import walk, CurrentDir, to_unicode content = functools.partial(os.path.join, u'content') @@ -386,6 +387,7 @@ def convert(htmlfile, opts, notification=None, create_epub=True, epub = initialize_container(opts.output) epub.add_dir(tdir) epub.close() + run_plugins_on_postprocess(opts.output, 'epub') logger.info(_('Output written to ')+opts.output) if opts.show_opf: diff --git a/src/calibre/ebooks/lit/writer.py b/src/calibre/ebooks/lit/writer.py index f824e6aac3..02981dac37 100644 --- a/src/calibre/ebooks/lit/writer.py +++ b/src/calibre/ebooks/lit/writer.py @@ -34,6 +34,7 @@ from calibre import LoggingInterface from calibre import plugins msdes, msdeserror = plugins['msdes'] import calibre.ebooks.lit.mssha1 as mssha1 +from calibre.customize.ui import run_plugins_on_postprocess __all__ = ['LitWriter'] @@ -734,6 +735,7 @@ def oeb2lit(opts, opfpath): lit = LitWriter(OEBBook(opfpath, logger=logger), logger=logger) with open(litpath, 'wb') as f: lit.dump(f) + run_plugins_on_postprocess(litpath, 'lit') logger.log_info(_('Output written to ')+litpath) diff --git a/src/calibre/ebooks/lrf/any/convert_from.py b/src/calibre/ebooks/lrf/any/convert_from.py index 710fbe1690..002064bced 100644 --- a/src/calibre/ebooks/lrf/any/convert_from.py +++ b/src/calibre/ebooks/lrf/any/convert_from.py @@ -18,6 +18,8 @@ from calibre.ebooks.lrf.epub.convert_from import process_file as epub2lrf from calibre.ebooks.lrf.mobi.convert_from import process_file as mobi2lrf from calibre.ebooks.lrf.fb2.convert_from import process_file as fb22lrf +from calibre.customize.ui import run_plugins_on_postprocess, run_plugins_on_preprocess + def largest_file(files): maxsize, file = 0, None for f in files: @@ -108,6 +110,7 @@ def odt2lrf(path, options, logger): def process_file(path, options, logger=None): path = os.path.abspath(os.path.expanduser(path)) + path = run_plugins_on_preprocess(path) tdir = None if logger is None: level = logging.DEBUG if options.verbose else logging.INFO @@ -160,6 +163,7 @@ def process_file(path, options, logger=None): if not convertor: raise UnknownFormatError(_('Converting from %s to LRF is not supported.')%ext) convertor(path, options, logger) + finally: os.chdir(cwd) if tdir and os.path.exists(tdir): diff --git a/src/calibre/ebooks/lrf/comic/convert_from.py b/src/calibre/ebooks/lrf/comic/convert_from.py index 4cd45dd893..5bc4a9171b 100755 --- a/src/calibre/ebooks/lrf/comic/convert_from.py +++ b/src/calibre/ebooks/lrf/comic/convert_from.py @@ -19,6 +19,7 @@ from calibre.ebooks.lrf.pylrs.pylrs import Book, BookSetting, ImageStream, Image from calibre.ebooks.metadata import MetaInformation from calibre.ebooks.metadata.opf import OPFCreator from calibre.ebooks.epub.from_html import config as html2epub_config, convert as html2epub +from calibre.customize.ui import run_plugins_on_preprocess try: from calibre.utils.PythonMagickWand import \ NewMagickWand, NewPixelWand, \ @@ -383,7 +384,9 @@ def create_lrf(pages, profile, opts, thumbnail=None): def do_convert(path_to_file, opts, notification=lambda m, p: p, output_format='lrf'): + path_to_file = run_plugins_on_preprocess(path_to_file) source = path_to_file + if not opts.title: opts.title = os.path.splitext(os.path.basename(source))[0] if not opts.output: diff --git a/src/calibre/ebooks/lrf/html/convert_from.py b/src/calibre/ebooks/lrf/html/convert_from.py index ae2ab233a5..9863f28bda 100644 --- a/src/calibre/ebooks/lrf/html/convert_from.py +++ b/src/calibre/ebooks/lrf/html/convert_from.py @@ -12,6 +12,7 @@ from urllib import unquote from urlparse import urlparse from math import ceil, floor from functools import partial +from calibre.customize.ui import run_plugins_on_postprocess try: from PIL import Image as PILImage @@ -1931,6 +1932,7 @@ def process_file(path, options, logger=None): oname = os.path.join(os.getcwd(), name) oname = os.path.abspath(os.path.expanduser(oname)) conv.writeto(oname, lrs=options.lrs) + run_plugins_on_postprocess(oname, 'lrf') logger.info('Output written to %s', oname) conv.cleanup() return oname diff --git a/src/calibre/gui2/dialogs/config.py b/src/calibre/gui2/dialogs/config.py index 6ee9a36904..69579bd377 100644 --- a/src/calibre/gui2/dialogs/config.py +++ b/src/calibre/gui2/dialogs/config.py @@ -6,19 +6,21 @@ from PyQt4.Qt import QDialog, QMessageBox, QListWidgetItem, QIcon, \ QDesktopServices, QVBoxLayout, QLabel, QPlainTextEdit, \ QStringListModel, QAbstractItemModel, \ SIGNAL, QTimer, Qt, QSize, QVariant, QUrl, \ - QModelIndex + QModelIndex, QInputDialog from calibre.constants import islinux, iswindows from calibre.gui2.dialogs.config_ui import Ui_Dialog from calibre.gui2 import qstring_to_unicode, choose_dir, error_dialog, config, \ - ALL_COLUMNS, NONE + ALL_COLUMNS, NONE, info_dialog, choose_files from calibre.utils.config import prefs from calibre.gui2.widgets import FilenamePattern from calibre.gui2.library import BooksModel from calibre.ebooks import BOOK_EXTENSIONS from calibre.ebooks.epub.iterator import is_supported from calibre.library import server_config -from calibre.customize.ui import initialized_plugins, is_disabled +from calibre.customize.ui import initialized_plugins, is_disabled, enable_plugin, \ + disable_plugin, customize_plugin, \ + plugin_customization, add_plugin class PluginModel(QAbstractItemModel): @@ -64,7 +66,21 @@ class PluginModel(QAbstractItemModel): def index_to_plugin(self, index): category = self.categories[index.parent().row()] return self._data[category][index.row()] - + + def plugin_to_index(self, plugin): + for i, category in enumerate(self.categories): + parent = self.index(i, 0, QModelIndex()) + for j, p in enumerate(self._data[category]): + if plugin == p: + return self.index(j, 0, parent) + return QModelIndex() + + def refresh_plugin(self, plugin, rescan=False): + if rescan: + self.populate() + idx = self.plugin_to_index(plugin) + self.emit(SIGNAL('dataChanged(QModelIndex,QModelIndex)'), idx, idx) + def flags(self, index): if not index.isValid(): return 0 @@ -86,8 +102,12 @@ class PluginModel(QAbstractItemModel): plugin = self.index_to_plugin(index) if role == Qt.DisplayRole: ver = '.'.join(map(str, plugin.version)) - desc = '\n'.join(textwrap.wrap(plugin.description, 70)) - return QVariant('%s (%s) by %s\n%s'%(plugin.name, ver, plugin.author, desc)) + desc = '\n'.join(textwrap.wrap(plugin.description, 50)) + ans='%s (%s) by %s\n%s'%(plugin.name, ver, plugin.author, desc) + c = plugin_customization(plugin) + if c: + ans += '\nCustomization: '+c + return QVariant(ans) if role == Qt.DecorationRole: return self.icon if role == Qt.UserRole: @@ -220,6 +240,54 @@ class ConfigDialog(QDialog, Ui_Dialog): self.category_view.setCurrentIndex(self._category_model.index(0)) self._plugin_model = PluginModel() self.plugin_view.setModel(self._plugin_model) + self.connect(self.toggle_plugin, SIGNAL('clicked()'), lambda : self.modify_plugin(op='toggle')) + self.connect(self.customize_plugin, SIGNAL('clicked()'), lambda : self.modify_plugin(op='customize')) + self.connect(self.button_plugin_browse, SIGNAL('clicked()'), self.find_plugin) + self.connect(self.button_plugin_add, SIGNAL('clicked()'), self.add_plugin) + + def add_plugin(self): + path = unicode(self.plugin_path.text()) + if path and os.access(path, os.R_OK) and path.lower().endswith('.zip'): + add_plugin(path) + self._plugin_model.populate() + self._plugin_model.reset() + else: + error_dialog(self, _('No valid plugin path'), + _('%s is not a valid plugin path')%path).exec_() + + def find_plugin(self): + path = choose_files(self, 'choose plugin dialog', _('Choose plugin'), + filters=[('Plugins', ['zip'])], all_files=False, + select_only_single_file=True) + if path: + self.plugin_path.setText(path[0]) + + def modify_plugin(self, op=''): + index = self.plugin_view.currentIndex() + if index.isValid(): + plugin = self._plugin_model.index_to_plugin(index) + if not plugin.can_be_disabled: + error_dialog(self,_('Plugin cannot be disabled'), + _('The plugin %s cannot be disabled')%plugin.name).exec_() + return + if op == 'toggle': + if is_disabled(plugin): + enable_plugin(plugin) + else: + disable_plugin(plugin) + self._plugin_model.refresh_plugin(plugin) + if op == 'customize': + if not plugin.is_customizable(): + info_dialog(self, _('Plugin not customizable'), + _('Plugin %s does not need customization')%plugin.name).exec_() + return + help = plugin.customization_help() + text, ok = QInputDialog.getText(self, _('Customize %s')%plugin.name, + help) + if ok: + customize_plugin(plugin, unicode(text)) + self._plugin_model.refresh_plugin(plugin) + def up_column(self): idx = self.columns.currentRow() diff --git a/src/calibre/gui2/dialogs/config.ui b/src/calibre/gui2/dialogs/config.ui index be95757c9d..6d8647c4b9 100644 --- a/src/calibre/gui2/dialogs/config.ui +++ b/src/calibre/gui2/dialogs/config.ui @@ -839,6 +839,10 @@ ... + + + :/images/document_open.svg:/images/document_open.svg + diff --git a/src/calibre/gui2/dialogs/metadata_single.py b/src/calibre/gui2/dialogs/metadata_single.py index f081bd5a67..d12dcf5cd3 100644 --- a/src/calibre/gui2/dialogs/metadata_single.py +++ b/src/calibre/gui2/dialogs/metadata_single.py @@ -85,11 +85,9 @@ class MetadataSingleDialog(QDialog, Ui_MetadataSingleDialog): QErrorMessage(self.window).showMessage("You do not have "+\ "permission to read the file: " + _file) continue - _file = run_plugins_on_import(_file, os.path.splitext(_file)[1].lower()) + _file = run_plugins_on_import(_file) size = os.stat(_file).st_size - ext = os.path.splitext(_file)[1].lower() - if '.' in ext: - ext = ext.replace('.', '') + ext = os.path.splitext(_file)[1].lower().replace('.', '') for row in range(self.formats.count()): fmt = self.formats.item(row) if fmt.ext == ext: diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 86e5ca8912..244ae72aeb 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -661,6 +661,7 @@ class LibraryDatabase2(LibraryDatabase): def add_format_with_hooks(self, index, format, fpath, index_is_id=False, path=None, notify=True): npath = self.run_import_plugins(fpath, format) + format = os.path.splitext(npath)[-1].lower().replace('.', '').upper() return self.add_format(index, format, open(npath, 'rb'), index_is_id=index_is_id, path=path, notify=notify) @@ -1126,6 +1127,7 @@ class LibraryDatabase2(LibraryDatabase): self.conn.commit() self.set_metadata(id, mi) npath = self.run_import_plugins(path, format) + format = os.path.splitext(npath)[-1].lower().replace('.', '').upper() stream = open(npath, 'rb') self.add_format(id, format, stream, index_is_id=True) stream.close()