diff --git a/src/calibre/customize/__init__.py b/src/calibre/customize/__init__.py new file mode 100644 index 0000000000..140d6878ab --- /dev/null +++ b/src/calibre/customize/__init__.py @@ -0,0 +1,69 @@ +from __future__ import with_statement +__license__ = 'GPL v3' +__copyright__ = '2008, Kovid Goyal ' + +class Plugin(object): + + #: List of platforms this plugin works on + #: For example: ``['windows', 'osx', 'linux'] + supported_platforms = [] + + #: The name of this plugin + name = 'Trivial Plugin' + + #: The version of this plugin as a 3-tuple (major, minor, revision) + version = (1, 0, 0) + + #: A short string describing what this plugin does + description = _('Does absolutely nothing') + + #: The author of this plugin + author = _('Unknown') + + #: When more than one plugin exists for a filetype, + #: the plugins are run in order of decreasing priority + #: i.e. plugins with higher priority will be run first. + #: The highest possible priority is ``sys.maxint``. + #: Default pririty is 1. + priority = 1 + + #: The earliest version of calibre this plugin requires + minimum_calibre_version = (0, 4, 118) + + def __init__(self, plugin_path): + ''' + Called once when calibre plugins are initialized. Plugins are re-initialized + every time a new plugin is added. + + :param plugin_path: Path to the zip file this plugin is distributed in. + ''' + self.plugin_path = plugin_path + + def customization_help(self): + ''' + Return a string giving help on how to customize this plugin. + By default raise a :class:`NotImplementedError`, which indicates that + the plugin does not require customization. + ''' + raise NotImplementedError + + def run(self, path_to_ebook, site_customization=''): + ''' + Run the plugin. Must be implemented in subclasses. + It should perform whatever modifications are required + on the ebook and return the absolute path to the + modified ebook. If no modifications are needed, it should + return the path to the original ebook. If an error is encountered + it should raise an Exception. The default implementation + simply return the path to the original ebook. + + :param path_to_ebook: Absolute path to the ebook + :param site_customization: A (possibly empty) string that the user + has specified to customize this plugin. + For example, it could be the path to a needed + executable on her system. + + :return: Absolute path to the modified ebook. + ''' + # Default implementation does nothing + return path_to_ebook \ No newline at end of file diff --git a/src/calibre/customize/filetype.py b/src/calibre/customize/filetype.py new file mode 100644 index 0000000000..765f810a89 --- /dev/null +++ b/src/calibre/customize/filetype.py @@ -0,0 +1,28 @@ +from __future__ import with_statement +__license__ = 'GPL v3' +__copyright__ = '2008, Kovid Goyal ' + +from calibre.customize import Plugin as PluginBase + +class Plugin(PluginBase): + ''' + A plugin that is associated with a particular set of file types. + ''' + + #: List of file types for which this plugin should be run + #: For example: ``['lit', 'mobi', 'prc']`` + file_types = [] + + #: If True, this plugin is run when books are added + #: to the database + on_import = False + + #: If True, this plugin is run whenever an any2* tool + #: is used, on the file passed to the any2* tool. + on_preprocess = False + + #: If True, this plugin is run after an any2* tool is + #: used, on the final file produced by the tool. + on_postprocess = False + + diff --git a/src/calibre/customize/ui.py b/src/calibre/customize/ui.py new file mode 100644 index 0000000000..22aba604b0 --- /dev/null +++ b/src/calibre/customize/ui.py @@ -0,0 +1,228 @@ +from __future__ import with_statement +__license__ = 'GPL v3' +__copyright__ = '2008, Kovid Goyal ' + +import os, shutil, traceback, functools, sys + +from calibre.customize import Plugin +from calibre.customize.filetype import Plugin as FileTypePlugin +from calibre.constants import __version__, iswindows, isosx +from calibre.utils.config import make_config_dir, Config, ConfigProxy, \ + plugin_dir, OptionParser + + +version = tuple([int(x) for x in __version__.split('.')]) + +platform = 'linux' +if iswindows: + platform = 'windows' +if isosx: + platform = 'osx' + +from zipfile import ZipFile + +def _config(): + c = Config('customize') + c.add_opt('plugins', default={}, help=_('Installed plugins')) + c.add_opt('filetype_mapping', default={}, help=_('Maping for filetype plugins')) + c.add_opt('plugin_customization', default={}, help=_('Local plugin customization')) + c.add_opt('disabled_plugins', default=set([]), help=_('Disabled plugins')) + + return ConfigProxy(c) + +config = _config() + + +class InvalidPlugin(ValueError): + pass + +def load_plugin(path_to_zip_file): + ''' + Load plugin from zip file or raise InvalidPlugin error + + :return: A :class:`Plugin` instance. + ''' + print 'Loading plugin from', path_to_zip_file + zf = ZipFile(path_to_zip_file) + for name in zf.namelist(): + if name.lower().endswith('plugin.py'): + locals = {} + exec zf.read(name) in locals + for x in locals.values(): + if isinstance(x, type) and issubclass(x, Plugin): + if x.minimum_calibre_version > version: + raise InvalidPlugin(_('%s needs calibre version at least %s')% + (x.name, x.minimum_calibre_version)) + if platform not in x.supported_platforms: + raise InvalidPlugin(_('%s is not supported on %s')% + (x.name, platform)) + + return x + raise InvalidPlugin(_('No valid plugin found in ')+path_to_zip_file) + +_initialized_plugins = [] +_on_import = {} +_on_preprocess = {} +_on_postprocess = {} + + + +def reread_filetype_plugins(): + global _on_import + global _on_preprocess + global _on_postprocess + _on_import = {} + _on_preprocess = {} + _on_postprocess = {} + + for plugin in _initialized_plugins: + if isinstance(plugin, FileTypePlugin): + for ft in plugin.file_types: + if plugin.on_import: + if not _on_import.has_key(ft): + _on_import[ft] = [] + _on_import[ft].append(plugin) + if plugin.on_preprocess: + if not _on_preprocess.has_key(ft): + _on_preprocess[ft] = [] + _on_preprocess[ft].append(plugin) + if plugin.on_postprocess: + if not _on_postprocess.has_key(ft): + _on_postprocess[ft] = [] + _on_postprocess[ft].append(plugin) + + +def _run_filetype_plugins(path_to_file, ft, occasion='preprocess'): + occasion = {'import':_on_import, 'preprocess':_on_preprocess, + 'postprocess':_on_postprocess}[occasion] + customization = config['plugin_customization'] + nfp = path_to_file + for plugin in occasion.get(ft, []): + if is_disabled(plugin): + continue + sc = customization.get(plugin.name, '') + try: + nfp = plugin.run(path_to_file, sc) + except: + print 'Running file type plugin %s failed with traceback:'%plugin.name + traceback.print_exc() + return nfp + +run_plugins_on_import = functools.partial(_run_filetype_plugins, + occasion='import') +run_plugins_on_preprocess = functools.partial(_run_filetype_plugins, + occasion='preprocess') +run_plugins_on_postprocess = functools.partial(_run_filetype_plugins, + occasion='postprocess') + + +def initialize_plugin(plugin, path_to_zip_file): + print 'Initializing plugin', plugin.name + try: + plugin(path_to_zip_file) + except Exception: + tb = traceback.format_exc() + raise InvalidPlugin((_('Initialization of plugin %s failed with traceback:') + %tb) + '\n'+tb) + + +def add_plugin(path_to_zip_file): + make_config_dir() + plugin = load_plugin(path_to_zip_file) + initialize_plugin(plugin, path_to_zip_file) + plugins = config['plugins'] + zfp = os.path.join(plugin_dir, 'name.zip') + if os.path.exists(zfp): + os.remove(zfp) + shutil.copyfile(path_to_zip_file, zfp) + plugins[plugin.name] = zfp + config['plugins'] = plugins + initialize_plugins() + return plugin + +def is_disabled(plugin): + return plugin.name in config['disabled_plugins'] + +def find_plugin(name): + for plugin in _initialized_plugins: + if plugin.name == name: + return plugin + +def disable_plugin(plugin_or_name): + x = getattr(plugin_or_name, 'name', plugin_or_name) + dp = config['disabled_plugins'] + dp.add(x) + config['disabled_plugins'] = dp + +def enable_plugin(plugin_or_name): + x = getattr(plugin_or_name, 'name', plugin_or_name) + dp = config['disabled_plugins'] + if x in dp: + dp.remove(x) + config['disabled_plugins'] = dp + +def initialize_plugins(): + global _initialized_plugins + _initialized_plugins = [] + for zfp in config['plugins'].values(): + try: + plugin = load_plugin(zfp) + initialize_plugin(plugin, zfp) + _initialized_plugins.append(plugin) + except: + print 'Failed to initialize plugin...' + traceback.print_exc() + _initialized_plugins.sort(cmp=lambda x,y:cmp(x.priority, y.priority), reverse=True) + reread_filetype_plugins() + +initialize_plugins() + +def option_parser(): + parser = OptionParser(usage=_('''\ + %prog options + + Customize calibre by loading external plugins. + ''')) + parser.add_option('-a', '--add-plugin', default=None, + help=_('Add a plugin by specifying the path to the zip file containing it.')) + parser.add_option('--customize-plugin', default=None, + help=_('Customize plugin. Specify name of plugin and customization string separated by a comma.')) + parser.add_option('-l', '--list-plugins', default=False, action='store_true', + help=_('List all installed plugins')) + parser.add_option('--enable-plugin', default=None, + help=_('Enable the named plugin')) + parser.add_option('--disable-plugin', default=None, + help=_('Disable the named plugin')) + return parser + +def main(args=sys.argv): + parser = option_parser() + if len(args) < 2: + parser.print_help() + return 1 + opts, args = parser.parse_args(args) + if opts.add_plugin is not None: + plugin = add_plugin(opts.add_plugin) + print 'Plugin added:', plugin.name, plugin.version + if opts.customize_plugin is not None: + name, custom = opts.customize_plugin.split(',') + plugin = find_plugin(name.strip()) + if plugin is None: + print 'No plugin with the name %s exists'%name + return 1 + config['plugin_customization'][plugin.name] = custom.strip() + if opts.enable_plugin is not None: + enable_plugin(opts.enable_plugin.strip()) + if opts.disable_plugin is not None: + disable_plugin(opts.disable_plugin.strip()) + if opts.list_plugins: + print 'Name\tVersion\tDisabled\tLocal Customization' + for plugin in _initialized_plugins: + print '%s\t%s\t%s\t%s'%(plugin.name, plugin.version, is_disabled(plugin), + config['plugin_customization'].get(plugin.name)) + print '\t', plugin.customization_help() + + return 0 + +if __name__ == '__main__': + sys.exit(main()) \ No newline at end of file diff --git a/src/calibre/debug.py b/src/calibre/debug.py index ea21a5613d..19c2a30b42 100644 --- a/src/calibre/debug.py +++ b/src/calibre/debug.py @@ -21,6 +21,8 @@ Run an embedded python interpreter. 'Module specifications are of the form full.name.of.module,path_to_module.py', default=None ) parser.add_option('-c', '--command', help='Run python code.', default=None) + parser.add_option('-g', '--run-gui', help='Run the GUI', default=False, + action='store_true') parser.add_option('--migrate', action='store_true', default=False, help='Migrate old database. Needs two arguments. Path to library1.db and path to new library folder.') return parser @@ -72,7 +74,10 @@ def migrate(old, new): def main(args=sys.argv): opts, args = option_parser().parse_args(args) - if opts.update_module: + if opts.run_gui: + from calibre.gui2.main import main + main() + elif opts.update_module: mod, path = opts.update_module.partition(',')[0], opts.update_module.partition(',')[-1] update_module(mod, os.path.expanduser(path)) elif opts.command: diff --git a/src/calibre/linux.py b/src/calibre/linux.py index 9e8dea355c..296024c251 100644 --- a/src/calibre/linux.py +++ b/src/calibre/linux.py @@ -62,7 +62,8 @@ entry_points = { 'calibre-debug = calibre.debug:main', 'calibredb = calibre.library.cli:main', 'calibre-fontconfig = calibre.utils.fontconfig:main', - 'calibre-parallel = calibre.parallel:main', + 'calibre-parallel = calibre.parallel:main', + 'calibre-customize = calibre.customize.ui:main', ], 'gui_scripts' : [ __appname__+' = calibre.gui2.main:main', diff --git a/src/calibre/utils/config.py b/src/calibre/utils/config.py index 59db9f38e1..7f7e47b498 100644 --- a/src/calibre/utils/config.py +++ b/src/calibre/utils/config.py @@ -28,9 +28,11 @@ else: bdir = os.path.abspath(os.path.expanduser(os.environ.get('XDG_CONFIG_HOME', '~/.config'))) config_dir = os.path.join(bdir, 'calibre') +plugin_dir = os.path.join(config_dir, 'plugins') + def make_config_dir(): - if not os.path.exists(config_dir): - os.makedirs(config_dir, mode=448) # 0700 == 448 + if not os.path.exists(plugin_dir): + os.makedirs(plugin_dir, mode=448) # 0700 == 448 class CustomHelpFormatter(IndentedHelpFormatter): @@ -78,6 +80,7 @@ class OptionParser(_OptionParser): gui_mode=False, conflict_handler='resolve', **kwds): + usage = textwrap.dedent(usage) usage += '''\n\nWhenever you pass arguments to %prog that have spaces in them, '''\ '''enclose the arguments in quotation marks.''' _OptionParser.__init__(self, usage=usage, version=version, epilog=epilog,