Add support for file type plugins to calibre, so that 3rd party developers can customize calibre.

This commit is contained in:
Kovid Goyal 2008-12-23 20:13:25 -08:00
parent 4d82cb5196
commit 660fa430f8
13 changed files with 134 additions and 24 deletions

View File

@ -2,7 +2,7 @@ __license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net' __copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
__docformat__ = 'restructuredtext en' __docformat__ = 'restructuredtext en'
__appname__ = 'calibre' __appname__ = 'calibre'
__version__ = '0.4.117' __version__ = '0.4.118'
__author__ = "Kovid Goyal <kovid@kovidgoyal.net>" __author__ = "Kovid Goyal <kovid@kovidgoyal.net>"
''' '''
Various run time constants. Various run time constants.

View File

@ -52,6 +52,12 @@ class Plugin(object):
#: The earliest version of calibre this plugin requires #: The earliest version of calibre this plugin requires
minimum_calibre_version = (0, 4, 118) 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') type = _('Base')
def __init__(self, plugin_path): def __init__(self, plugin_path):
@ -71,8 +77,7 @@ class Plugin(object):
''' '''
pass pass
@classmethod def customization_help(self, gui=False):
def customization_help(cls):
''' '''
Return a string giving help on how to customize this plugin. Return a string giving help on how to customize this plugin.
By default raise a :class:`NotImplementedError`, which indicates that By default raise a :class:`NotImplementedError`, which indicates that
@ -85,6 +90,8 @@ class Plugin(object):
Site customization could be anything, for example, the path to 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 raise NotImplementedError
@ -99,10 +106,9 @@ class Plugin(object):
''' '''
return PersistentTemporaryFile(suffix) return PersistentTemporaryFile(suffix)
@classmethod def is_customizable(self):
def is_customizable(cls):
try: try:
cls.customization_help() self.customization_help()
return True return True
except NotImplementedError: except NotImplementedError:
return False return False

View File

@ -92,10 +92,12 @@ def reread_filetype_plugins():
_on_postprocess[ft].append(plugin) _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, occasion = {'import':_on_import, 'preprocess':_on_preprocess,
'postprocess':_on_postprocess}[occasion] 'postprocess':_on_postprocess}[occasion]
customization = config['plugin_customization'] customization = config['plugin_customization']
if ft is None:
ft = os.path.splitext(path_to_file)[-1].lower().replace('.', '')
nfp = path_to_file nfp = path_to_file
for plugin in occasion.get(ft, []): for plugin in occasion.get(ft, []):
if is_disabled(plugin): if is_disabled(plugin):
@ -107,6 +109,10 @@ def _run_filetype_plugins(path_to_file, ft, occasion='preprocess'):
except: except:
print 'Running file type plugin %s failed with traceback:'%plugin.name print 'Running file type plugin %s failed with traceback:'%plugin.name
traceback.print_exc() 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 return nfp
run_plugins_on_import = functools.partial(_run_filetype_plugins, 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): def initialize_plugin(plugin, path_to_zip_file):
try: try:
plugin(path_to_zip_file) return plugin(path_to_zip_file)
except Exception: except Exception:
print 'Failed to initialize plugin:', plugin.name, plugin.version print 'Failed to initialize plugin:', plugin.name, plugin.version
tb = traceback.format_exc() tb = traceback.format_exc()
@ -130,9 +136,9 @@ def initialize_plugin(plugin, path_to_zip_file):
def add_plugin(path_to_zip_file): def add_plugin(path_to_zip_file):
make_config_dir() make_config_dir()
plugin = load_plugin(path_to_zip_file) 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'] 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): if os.path.exists(zfp):
os.remove(zfp) os.remove(zfp)
shutil.copyfile(path_to_zip_file, zfp) shutil.copyfile(path_to_zip_file, zfp)
@ -151,6 +157,9 @@ def find_plugin(name):
def disable_plugin(plugin_or_name): def disable_plugin(plugin_or_name):
x = getattr(plugin_or_name, 'name', 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 = config['disabled_plugins']
dp.add(x) dp.add(x)
config['disabled_plugins'] = dp config['disabled_plugins'] = dp
@ -168,7 +177,7 @@ def initialize_plugins():
for zfp in list(config['plugins'].values()) + builtin_plugins: for zfp in list(config['plugins'].values()) + builtin_plugins:
try: try:
plugin = load_plugin(zfp) if not isinstance(zfp, type) else zfp 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) _initialized_plugins.append(plugin)
except: except:
print 'Failed to initialize plugin...' print 'Failed to initialize plugin...'
@ -199,6 +208,14 @@ def option_parser():
def initialized_plugins(): def initialized_plugins():
return _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): def main(args=sys.argv):
parser = option_parser() parser = option_parser()
if len(args) < 2: if len(args) < 2:
@ -214,7 +231,7 @@ def main(args=sys.argv):
if plugin is None: if plugin is None:
print 'No plugin with the name %s exists'%name print 'No plugin with the name %s exists'%name
return 1 return 1
config['plugin_customization'][plugin.name] = custom.strip() customize_plugin(plugin, custom)
if opts.enable_plugin is not None: if opts.enable_plugin is not None:
enable_plugin(opts.enable_plugin.strip()) enable_plugin(opts.enable_plugin.strip())
if opts.disable_plugin is not None: if opts.disable_plugin is not None:
@ -227,7 +244,7 @@ def main(args=sys.argv):
print fmt%( print fmt%(
plugin.type, plugin.name, plugin.type, plugin.name,
plugin.version, is_disabled(plugin), plugin.version, is_disabled(plugin),
config['plugin_customization'].get(plugin.name, '') plugin_customization(plugin)
) )
print '\t', plugin.description print '\t', plugin.description
if plugin.is_customizable(): if plugin.is_customizable():

View File

@ -18,6 +18,7 @@ from calibre.ptempfile import TemporaryDirectory
from calibre.ebooks.metadata import MetaInformation from calibre.ebooks.metadata import MetaInformation
from calibre.ebooks.metadata.opf2 import OPFCreator from calibre.ebooks.metadata.opf2 import OPFCreator
from calibre.utils.zipfile import ZipFile from calibre.utils.zipfile import ZipFile
from calibre.customize.ui import run_plugins_on_preprocess
def lit2opf(path, tdir, opts): def lit2opf(path, tdir, opts):
from calibre.ebooks.lit.reader import LitReader from calibre.ebooks.lit.reader import LitReader
@ -118,6 +119,7 @@ def unarchive(path, tdir):
def any2epub(opts, path, notification=None, create_epub=True, def any2epub(opts, path, notification=None, create_epub=True,
oeb_cover=False, extract_to=None): oeb_cover=False, extract_to=None):
path = run_plugins_on_preprocess(path)
ext = os.path.splitext(path)[1] ext = os.path.splitext(path)[1]
if not ext: if not ext:
raise ValueError('Unknown file type: '+path) raise ValueError('Unknown file type: '+path)

View File

@ -48,6 +48,7 @@ from calibre.ebooks.epub import initialize_container, PROFILES
from calibre.ebooks.epub.split import split from calibre.ebooks.epub.split import split
from calibre.ebooks.epub.fonts import Rationalizer from calibre.ebooks.epub.fonts import Rationalizer
from calibre.constants import preferred_encoding from calibre.constants import preferred_encoding
from calibre.customize.ui import run_plugins_on_postprocess
from calibre import walk, CurrentDir, to_unicode from calibre import walk, CurrentDir, to_unicode
content = functools.partial(os.path.join, u'content') 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 = initialize_container(opts.output)
epub.add_dir(tdir) epub.add_dir(tdir)
epub.close() epub.close()
run_plugins_on_postprocess(opts.output, 'epub')
logger.info(_('Output written to ')+opts.output) logger.info(_('Output written to ')+opts.output)
if opts.show_opf: if opts.show_opf:

View File

@ -34,6 +34,7 @@ from calibre import LoggingInterface
from calibre import plugins from calibre import plugins
msdes, msdeserror = plugins['msdes'] msdes, msdeserror = plugins['msdes']
import calibre.ebooks.lit.mssha1 as mssha1 import calibre.ebooks.lit.mssha1 as mssha1
from calibre.customize.ui import run_plugins_on_postprocess
__all__ = ['LitWriter'] __all__ = ['LitWriter']
@ -734,6 +735,7 @@ def oeb2lit(opts, opfpath):
lit = LitWriter(OEBBook(opfpath, logger=logger), logger=logger) lit = LitWriter(OEBBook(opfpath, logger=logger), logger=logger)
with open(litpath, 'wb') as f: with open(litpath, 'wb') as f:
lit.dump(f) lit.dump(f)
run_plugins_on_postprocess(litpath, 'lit')
logger.log_info(_('Output written to ')+litpath) logger.log_info(_('Output written to ')+litpath)

View File

@ -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.mobi.convert_from import process_file as mobi2lrf
from calibre.ebooks.lrf.fb2.convert_from import process_file as fb22lrf 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): def largest_file(files):
maxsize, file = 0, None maxsize, file = 0, None
for f in files: for f in files:
@ -108,6 +110,7 @@ def odt2lrf(path, options, logger):
def process_file(path, options, logger=None): def process_file(path, options, logger=None):
path = os.path.abspath(os.path.expanduser(path)) path = os.path.abspath(os.path.expanduser(path))
path = run_plugins_on_preprocess(path)
tdir = None tdir = None
if logger is None: if logger is None:
level = logging.DEBUG if options.verbose else logging.INFO level = logging.DEBUG if options.verbose else logging.INFO
@ -160,6 +163,7 @@ def process_file(path, options, logger=None):
if not convertor: if not convertor:
raise UnknownFormatError(_('Converting from %s to LRF is not supported.')%ext) raise UnknownFormatError(_('Converting from %s to LRF is not supported.')%ext)
convertor(path, options, logger) convertor(path, options, logger)
finally: finally:
os.chdir(cwd) os.chdir(cwd)
if tdir and os.path.exists(tdir): if tdir and os.path.exists(tdir):

View File

@ -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 import MetaInformation
from calibre.ebooks.metadata.opf import OPFCreator from calibre.ebooks.metadata.opf import OPFCreator
from calibre.ebooks.epub.from_html import config as html2epub_config, convert as html2epub from calibre.ebooks.epub.from_html import config as html2epub_config, convert as html2epub
from calibre.customize.ui import run_plugins_on_preprocess
try: try:
from calibre.utils.PythonMagickWand import \ from calibre.utils.PythonMagickWand import \
NewMagickWand, NewPixelWand, \ 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'): 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 source = path_to_file
if not opts.title: if not opts.title:
opts.title = os.path.splitext(os.path.basename(source))[0] opts.title = os.path.splitext(os.path.basename(source))[0]
if not opts.output: if not opts.output:

View File

@ -12,6 +12,7 @@ from urllib import unquote
from urlparse import urlparse from urlparse import urlparse
from math import ceil, floor from math import ceil, floor
from functools import partial from functools import partial
from calibre.customize.ui import run_plugins_on_postprocess
try: try:
from PIL import Image as PILImage 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.join(os.getcwd(), name)
oname = os.path.abspath(os.path.expanduser(oname)) oname = os.path.abspath(os.path.expanduser(oname))
conv.writeto(oname, lrs=options.lrs) conv.writeto(oname, lrs=options.lrs)
run_plugins_on_postprocess(oname, 'lrf')
logger.info('Output written to %s', oname) logger.info('Output written to %s', oname)
conv.cleanup() conv.cleanup()
return oname return oname

View File

@ -6,19 +6,21 @@ from PyQt4.Qt import QDialog, QMessageBox, QListWidgetItem, QIcon, \
QDesktopServices, QVBoxLayout, QLabel, QPlainTextEdit, \ QDesktopServices, QVBoxLayout, QLabel, QPlainTextEdit, \
QStringListModel, QAbstractItemModel, \ QStringListModel, QAbstractItemModel, \
SIGNAL, QTimer, Qt, QSize, QVariant, QUrl, \ SIGNAL, QTimer, Qt, QSize, QVariant, QUrl, \
QModelIndex QModelIndex, QInputDialog
from calibre.constants import islinux, iswindows from calibre.constants import islinux, iswindows
from calibre.gui2.dialogs.config_ui import Ui_Dialog from calibre.gui2.dialogs.config_ui import Ui_Dialog
from calibre.gui2 import qstring_to_unicode, choose_dir, error_dialog, config, \ 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.utils.config import prefs
from calibre.gui2.widgets import FilenamePattern from calibre.gui2.widgets import FilenamePattern
from calibre.gui2.library import BooksModel from calibre.gui2.library import BooksModel
from calibre.ebooks import BOOK_EXTENSIONS from calibre.ebooks import BOOK_EXTENSIONS
from calibre.ebooks.epub.iterator import is_supported from calibre.ebooks.epub.iterator import is_supported
from calibre.library import server_config 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): class PluginModel(QAbstractItemModel):
@ -65,6 +67,20 @@ class PluginModel(QAbstractItemModel):
category = self.categories[index.parent().row()] category = self.categories[index.parent().row()]
return self._data[category][index.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): def flags(self, index):
if not index.isValid(): if not index.isValid():
return 0 return 0
@ -86,8 +102,12 @@ class PluginModel(QAbstractItemModel):
plugin = self.index_to_plugin(index) plugin = self.index_to_plugin(index)
if role == Qt.DisplayRole: if role == Qt.DisplayRole:
ver = '.'.join(map(str, plugin.version)) ver = '.'.join(map(str, plugin.version))
desc = '\n'.join(textwrap.wrap(plugin.description, 70)) desc = '\n'.join(textwrap.wrap(plugin.description, 50))
return QVariant('%s (%s) by %s\n%s'%(plugin.name, ver, plugin.author, desc)) 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: if role == Qt.DecorationRole:
return self.icon return self.icon
if role == Qt.UserRole: if role == Qt.UserRole:
@ -220,6 +240,54 @@ class ConfigDialog(QDialog, Ui_Dialog):
self.category_view.setCurrentIndex(self._category_model.index(0)) self.category_view.setCurrentIndex(self._category_model.index(0))
self._plugin_model = PluginModel() self._plugin_model = PluginModel()
self.plugin_view.setModel(self._plugin_model) 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): def up_column(self):
idx = self.columns.currentRow() idx = self.columns.currentRow()

View File

@ -839,6 +839,10 @@
<property name="text" > <property name="text" >
<string>...</string> <string>...</string>
</property> </property>
<property name="icon" >
<iconset resource="../images.qrc" >
<normaloff>:/images/document_open.svg</normaloff>:/images/document_open.svg</iconset>
</property>
</widget> </widget>
</item> </item>
</layout> </layout>

View File

@ -85,11 +85,9 @@ class MetadataSingleDialog(QDialog, Ui_MetadataSingleDialog):
QErrorMessage(self.window).showMessage("You do not have "+\ QErrorMessage(self.window).showMessage("You do not have "+\
"permission to read the file: " + _file) "permission to read the file: " + _file)
continue 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 size = os.stat(_file).st_size
ext = os.path.splitext(_file)[1].lower() ext = os.path.splitext(_file)[1].lower().replace('.', '')
if '.' in ext:
ext = ext.replace('.', '')
for row in range(self.formats.count()): for row in range(self.formats.count()):
fmt = self.formats.item(row) fmt = self.formats.item(row)
if fmt.ext == ext: if fmt.ext == ext:

View File

@ -661,6 +661,7 @@ class LibraryDatabase2(LibraryDatabase):
def add_format_with_hooks(self, index, format, fpath, index_is_id=False, def add_format_with_hooks(self, index, format, fpath, index_is_id=False,
path=None, notify=True): path=None, notify=True):
npath = self.run_import_plugins(fpath, format) 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'), return self.add_format(index, format, open(npath, 'rb'),
index_is_id=index_is_id, path=path, notify=notify) index_is_id=index_is_id, path=path, notify=notify)
@ -1126,6 +1127,7 @@ class LibraryDatabase2(LibraryDatabase):
self.conn.commit() self.conn.commit()
self.set_metadata(id, mi) self.set_metadata(id, mi)
npath = self.run_import_plugins(path, format) npath = self.run_import_plugins(path, format)
format = os.path.splitext(npath)[-1].lower().replace('.', '').upper()
stream = open(npath, 'rb') stream = open(npath, 'rb')
self.add_format(id, format, stream, index_is_id=True) self.add_format(id, format, stream, index_is_id=True)
stream.close() stream.close()