From f74530210fdde5a632c88d28f15b7beed9c8ef34 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Sun, 26 Sep 2010 21:01:55 +0100 Subject: [PATCH 01/13] Make composite columns sort case-insensitive. --- src/calibre/library/caches.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index 09adc4a9fd..6cd0c227dd 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -780,7 +780,7 @@ class SortKeyGenerator(object): sidx = record[sidx_fm['rec_index']] val = (val, sidx) - elif dt in ('text', 'comments'): + elif dt in ('text', 'comments', 'composite'): if val is None: val = '' val = val.lower() From 1dee223f14d413f5969e1ff7b261882b5779be0d Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 27 Sep 2010 15:12:25 +0100 Subject: [PATCH 02/13] First implementation of plugboards --- src/calibre/customize/builtins.py | 15 +- src/calibre/ebooks/metadata/book/base.py | 29 ++- src/calibre/gui2/device.py | 32 ++- src/calibre/gui2/preferences/plugboard.py | 257 ++++++++++++++++++++++ src/calibre/gui2/preferences/plugboard.ui | 138 ++++++++++++ src/calibre/library/save_to_disk.py | 22 +- 6 files changed, 484 insertions(+), 9 deletions(-) create mode 100644 src/calibre/gui2/preferences/plugboard.py create mode 100644 src/calibre/gui2/preferences/plugboard.ui diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py index 4e47c70bb0..89c800afb2 100644 --- a/src/calibre/customize/builtins.py +++ b/src/calibre/customize/builtins.py @@ -796,6 +796,17 @@ class Sending(PreferencesPlugin): description = _('Control how calibre transfers files to your ' 'ebook reader') +class Plugboard(PreferencesPlugin): + name = 'Plugboard' + icon = I('plugboard.png') + gui_name = _('Metadata plugboard') + category = 'Import/Export' + gui_category = _('Import/Export') + category_order = 3 + name_order = 4 + config_widget = 'calibre.gui2.preferences.plugboard' + description = _('Change metadata fields before saving/sending') + class Email(PreferencesPlugin): name = 'Email' icon = I('mail.png') @@ -856,8 +867,8 @@ class Misc(PreferencesPlugin): description = _('Miscellaneous advanced configuration') plugins += [LookAndFeel, Behavior, Columns, Toolbar, InputOptions, - CommonOptions, OutputOptions, Adding, Saving, Sending, Email, Server, - Plugins, Tweaks, Misc] + CommonOptions, OutputOptions, Adding, Saving, Sending, Plugboard, + Email, Server, Plugins, Tweaks, Misc] #}}} diff --git a/src/calibre/ebooks/metadata/book/base.py b/src/calibre/ebooks/metadata/book/base.py index bf95e989e8..aaa7c78e9a 100644 --- a/src/calibre/ebooks/metadata/book/base.py +++ b/src/calibre/ebooks/metadata/book/base.py @@ -182,7 +182,7 @@ class Metadata(object): return metadata describing a standard or custom field. ''' if key not in self.custom_field_keys(): - return self.get_standard_metadata(self, key, make_copy=False) + return self.get_standard_metadata(key, make_copy=False) return self.get_user_metadata(key, make_copy=False) def all_non_none_fields(self): @@ -294,6 +294,33 @@ class Metadata(object): _data = object.__getattribute__(self, '_data') _data['user_metadata'][field] = metadata + def copy_specific_attributes(self, other, attrs): + ''' + Takes a dict {src:dest, src:dest} and copys other[src] to self[dest]. + This is on a best-efforts basis. Some assignments can make no sense. + ''' + if not attrs: + return + for src in attrs: + try: + print src + sfm = other.metadata_for_field(src) + dfm = self.metadata_for_field(attrs[src]) + if dfm['is_multiple']: + if sfm['is_multiple']: + self.set(attrs[src], other.get(src)) + else: + self.set(attrs[src], + [f.strip() for f in other.get(src).split(',') + if f.strip()]) + elif sfm['is_multiple']: + self.set(attrs[src], ','.join(other.get(src))) + else: + self.set(attrs[src], other.get(src)) + except: + traceback.print_exc() + pass + # Old Metadata API {{{ def print_all_attributes(self): for x in STANDARD_METADATA_FIELDS: diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index 58c5e5d9ad..eb1716f782 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -317,19 +317,40 @@ class DeviceManager(Thread): # {{{ args=[booklist, on_card], description=_('Send collections to device')) - def _upload_books(self, files, names, on_card=None, metadata=None): + def _upload_books(self, files, names, on_card=None, metadata=None, plugboards=None): '''Upload books to device: ''' if metadata and files and len(metadata) == len(files): for f, mi in zip(files, metadata): if isinstance(f, unicode): ext = f.rpartition('.')[-1].lower() + dev_name = self.connected_device.name + cpb = None + if ext in plugboards: + cpb = plugboards[ext] + elif ' any' in plugboards: + cpb = plugboards[' any'] + if cpb is not None: + if dev_name in cpb: + cpb = cpb[dev_name] + elif ' any' in plugboards[ext]: + cpb = cpb[' any'] + else: + cpb = None + + if DEBUG: + prints('Using plugboard', cpb) if ext: try: if DEBUG: prints('Setting metadata in:', mi.title, 'at:', f, file=sys.__stdout__) with open(f, 'r+b') as stream: - set_metadata(stream, mi, stream_type=ext) + if cpb: + newmi = mi.deepcopy() + newmi.copy_specific_attributes(mi, cpb) + else: + newmi = mi + set_metadata(stream, newmi, stream_type=ext) except: if DEBUG: prints(traceback.format_exc(), file=sys.__stdout__) @@ -338,12 +359,12 @@ class DeviceManager(Thread): # {{{ metadata=metadata, end_session=False) def upload_books(self, done, files, names, on_card=None, titles=None, - metadata=None): + metadata=None, plugboards=None): desc = _('Upload %d books to device')%len(names) if titles: desc += u':' + u', '.join(titles) return self.create_job(self._upload_books, done, args=[files, names], - kwargs={'on_card':on_card,'metadata':metadata}, description=desc) + kwargs={'on_card':on_card,'metadata':metadata,'plugboards':plugboards}, description=desc) def add_books_to_metadata(self, locations, metadata, booklists): self.device.add_books_to_metadata(locations, metadata, booklists) @@ -1257,10 +1278,11 @@ class DeviceMixin(object): # {{{ :param files: List of either paths to files or file like objects ''' titles = [i.title for i in metadata] + plugboards = self.library_view.model().db.prefs.get('plugboards', None) job = self.device_manager.upload_books( Dispatcher(self.books_uploaded), files, names, on_card=on_card, - metadata=metadata, titles=titles + metadata=metadata, titles=titles, plugboards=plugboards ) self.upload_memory[job] = (metadata, on_card, memory, files) diff --git a/src/calibre/gui2/preferences/plugboard.py b/src/calibre/gui2/preferences/plugboard.py new file mode 100644 index 0000000000..5691120cef --- /dev/null +++ b/src/calibre/gui2/preferences/plugboard.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai + +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +from PyQt4 import QtGui + +from calibre.gui2 import error_dialog +from calibre.gui2.preferences import ConfigWidgetBase, test_widget, \ + AbortCommit +from calibre.gui2.preferences.plugboard_ui import Ui_Form +from calibre.customize.ui import metadata_writers, device_plugins + + +class ConfigWidget(ConfigWidgetBase, Ui_Form): + + def genesis(self, gui): + self.gui = gui + self.db = gui.library_view.model().db + self.current_plugboards = self.db.prefs.get('plugboards', {'epub': {' any': {'title':'authors', 'authors':'tags'}}}) + self.current_device = None + self.current_format = None +# self.proxy = ConfigProxy(config()) +# +# r = self.register +# +# for x in ('asciiize', 'update_metadata', 'save_cover', 'write_opf', +# 'replace_whitespace', 'to_lowercase', 'formats', 'timefmt'): +# r(x, self.proxy) +# +# self.save_template.changed_signal.connect(self.changed_signal.emit) + + def clear_fields(self, edit_boxes=False, new_boxes=False): + self.ok_button.setEnabled(False) + for w in self.source_widgets: + w.clear() + for w in self.dest_widgets: + w.clear() + if edit_boxes: + self.edit_device.setCurrentIndex(0) + self.edit_format.setCurrentIndex(0) + if new_boxes: + self.new_device.setCurrentIndex(0) + self.new_format.setCurrentIndex(0) + + def set_fields(self): + self.ok_button.setEnabled(True) + for w in self.source_widgets: + w.addItems(self.fields) + for w in self.dest_widgets: + w.addItems(self.fields) + + def set_field(self, i, src, dst): + print i, src, dst + idx = self.fields.index(src) + self.source_widgets[i].setCurrentIndex(idx) + idx = self.fields.index(dst) + self.dest_widgets[i].setCurrentIndex(idx) + + def edit_device_changed(self, txt): + if txt == '': + self.current_device = None + return + print 'edit device changed' + self.clear_fields(new_boxes=True) + self.current_device = unicode(txt) + fpb = self.current_plugboards.get(self.current_format, None) + if fpb is None: + print 'None format!' + return + dpb = fpb.get(self.current_device, None) + if dpb is None: + print 'none device!' + return + self.set_fields() + for i,src in enumerate(dpb): + self.set_field(i, src, dpb[src]) + self.ok_button.setEnabled(True) + + def edit_format_changed(self, txt): + if txt == '': + self.edit_device.setCurrentIndex(0) + self.current_format = None + self.current_device = None + return + print 'edit_format_changed' + self.clear_fields(new_boxes=True) + txt = unicode(txt) + fpb = self.current_plugboards.get(txt, None) + if fpb is None: + print 'None editable format!' + return + self.current_format = txt + devices = [''] + for d in fpb: + devices.append(d) + self.edit_device.clear() + self.edit_device.addItems(devices) + self.edit_device.setCurrentIndex(0) + + def new_device_changed(self, txt): + if txt == '': + self.current_device = None + return + print 'new_device_changed' + self.clear_fields(edit_boxes=True) + self.current_device = unicode(txt) + error = False + if self.current_format == ' any': + for f in self.current_plugboards: + if self.current_device == ' any' and len(self.current_plugboards[f]): + error = True + break + if self.current_device in self.current_plugboards[f]: + error = True + break + if ' any' in self.current_plugboards[f]: + error = True + break + else: + fpb = self.current_plugboards.get(self.current_format, None) + if fpb is not None: + if ' any' in fpb: + error = True + else: + dpb = fpb.get(self.current_device, None) + if dpb is not None: + error = True + + if error: + error_dialog(self, '', + _('That format and device already has a plugboard'), + show=True) + self.new_device.setCurrentIndex(0) + return + self.set_fields() + + def new_format_changed(self, txt): + if txt == '': + self.current_format = None + self.current_device = None + return + print 'new_format_changed' + self.clear_fields(edit_boxes=True) + self.current_format = unicode(txt) + self.new_device.setCurrentIndex(0) + + def ok_clicked(self): + pb = {} + print self.current_format, self.current_device + for i in range(0, len(self.source_widgets)): + s = self.source_widgets[i].currentIndex() + if s != 0: + d = self.dest_widgets[i].currentIndex() + if d != 0: + pb[self.fields[s]] = self.fields[d] + if len(pb) == 0: + if self.current_format in self.current_plugboards: + fpb = self.current_plugboards[self.current_format] + if self.current_device in fpb: + del fpb[self.current_device] + if len(fpb) == 0: + del self.current_plugboards[self.current_format] + else: + if self.current_format not in self.current_plugboards: + self.current_plugboards[self.current_format] = {} + fpb = self.current_plugboards[self.current_format] + fpb[self.current_device] = pb + self.changed_signal.emit() + self.refill_all_boxes() + + def refill_all_boxes(self): + self.current_device = None + self.current_format = None + self.clear_fields(new_boxes=True) + self.edit_format.clear() + self.edit_format.addItem('') + for format in self.current_plugboards: + self.edit_format.addItem(format) + self.edit_format.setCurrentIndex(0) + self.edit_device.clear() + self.ok_button.setEnabled(False) + + def initialize(self): + def field_cmp(x, y): + if x.startswith('#'): + if y.startswith('#'): + return cmp(x.lower(), y.lower()) + else: + return 1 + elif y.startswith('#'): + return -1 + else: + return cmp(x.lower(), y.lower()) + + ConfigWidgetBase.initialize(self) + + self.devices = ['', ' any', 'save to disk'] + for device in device_plugins(): + self.devices.append(device.name) + self.devices.sort(cmp=lambda x, y: cmp(x.lower(), y.lower())) + self.new_device.addItems(self.devices) + + self.formats = ['', ' any'] + for w in metadata_writers(): + for f in w.file_types: + self.formats.append(f) + self.formats.sort() + self.new_format.addItems(self.formats) + + self.fields = [''] + for f in self.db.all_field_keys(): + if self.db.field_metadata[f].get('rec_index', None) is not None and\ + self.db.field_metadata[f]['datatype'] is not None and \ + self.db.field_metadata[f]['search_terms']: + self.fields.append(f) + self.fields.sort(cmp=field_cmp) + + self.source_widgets = [] + self.dest_widgets = [] + for i in range(0, 10): + w = QtGui.QComboBox(self) + self.source_widgets.append(w) + self.fields_layout.addWidget(w, 5+i, 0, 1, 1) + w = QtGui.QComboBox(self) + self.dest_widgets.append(w) + self.fields_layout.addWidget(w, 5+i, 1, 1, 1) + + self.edit_device.currentIndexChanged[str].connect(self.edit_device_changed) + self.edit_format.currentIndexChanged[str].connect(self.edit_format_changed) + self.new_device.currentIndexChanged[str].connect(self.new_device_changed) + self.new_format.currentIndexChanged[str].connect(self.new_format_changed) + self.ok_button.clicked.connect(self.ok_clicked) + + self.refill_all_boxes() + + def restore_defaults(self): + ConfigWidgetBase.restore_defaults(self) + self.current_plugboards = {} + self.refill_all_boxes() + self.changed_signal.emit() + + def commit(self): + self.db.prefs.set('plugboards', self.current_plugboards) + return ConfigWidgetBase.commit(self) + + def refresh_gui(self, gui): + pass + + +if __name__ == '__main__': + from PyQt4.Qt import QApplication + app = QApplication([]) + test_widget('Import/Export', 'plugboards') + diff --git a/src/calibre/gui2/preferences/plugboard.ui b/src/calibre/gui2/preferences/plugboard.ui new file mode 100644 index 0000000000..ad72ec359f --- /dev/null +++ b/src/calibre/gui2/preferences/plugboard.ui @@ -0,0 +1,138 @@ + + + Form + + + + 0 + 0 + 707 + 340 + + + + Form + + + + + + Here you can control what metadata calibre uses when saving or sending books: + + + true + + + + + + + + + Add new plugboard + + + + + + + Edit existing plugboard + + + + + + + + + + + + + + + + + + + Format (choose first) + + + Qt::AlignCenter + + + + + + + Device (choose second) + + + Qt::AlignCenter + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + Source field + + + Qt::AlignCenter + + + + + + + Destination field + + + Qt::AlignCenter + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Done + + + + + + + + + + diff --git a/src/calibre/library/save_to_disk.py b/src/calibre/library/save_to_disk.py index e479d27121..54671da4b4 100644 --- a/src/calibre/library/save_to_disk.py +++ b/src/calibre/library/save_to_disk.py @@ -232,6 +232,21 @@ def save_book_to_disk(id, db, root, opts, length): written = False for fmt in formats: + dev_name = 'save to disk' + plugboards = db.prefs.get('plugboards', None) + cpb = None + if fmt in plugboards: + cpb = plugboards[fmt] + elif ' any' in plugboards: + cpb = plugboards[' any'] + if cpb is not None: + if dev_name in cpb: + cpb = cpb[dev_name] + elif ' any' in plugboards[fmt]: + cpb = cpb[' any'] + else: + cpb = None + data = db.format(id, fmt, index_is_id=True) if data is None: continue @@ -242,7 +257,12 @@ def save_book_to_disk(id, db, root, opts, length): stream.write(data) stream.seek(0) try: - set_metadata(stream, mi, fmt) + if cpb: + newmi = mi.deepcopy() + newmi.copy_specific_attributes(mi, cpb) + else: + newmi = mi + set_metadata(stream, newmi, fmt) except: traceback.print_exc() stream.seek(0) From d6fc61d1b706dffe4063aa63f78245ee48401a4a Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 27 Sep 2010 15:14:55 +0100 Subject: [PATCH 03/13] Add the icon --- resources/images/plugboard.png | Bin 0 -> 31806 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 resources/images/plugboard.png diff --git a/resources/images/plugboard.png b/resources/images/plugboard.png new file mode 100644 index 0000000000000000000000000000000000000000..88f0869b8d9de4552184308b934889c5c83ec85f GIT binary patch literal 31806 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 z

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%0DdusUKrIkg&IKYRAV3uX#C_to6p@@YXYaor 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 rQ#3{?nhp Date: Mon, 27 Sep 2010 15:17:16 +0100 Subject: [PATCH 04/13] Remove print statements --- src/calibre/gui2/preferences/plugboard.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/calibre/gui2/preferences/plugboard.py b/src/calibre/gui2/preferences/plugboard.py index 5691120cef..7fdd093dc1 100644 --- a/src/calibre/gui2/preferences/plugboard.py +++ b/src/calibre/gui2/preferences/plugboard.py @@ -53,7 +53,6 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): w.addItems(self.fields) def set_field(self, i, src, dst): - print i, src, dst idx = self.fields.index(src) self.source_widgets[i].setCurrentIndex(idx) idx = self.fields.index(dst) @@ -63,16 +62,15 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): if txt == '': self.current_device = None return - print 'edit device changed' self.clear_fields(new_boxes=True) self.current_device = unicode(txt) fpb = self.current_plugboards.get(self.current_format, None) if fpb is None: - print 'None format!' + print 'edit_device_changed: none format!' return dpb = fpb.get(self.current_device, None) if dpb is None: - print 'none device!' + print 'edit_device_changed: none device!' return self.set_fields() for i,src in enumerate(dpb): @@ -85,12 +83,11 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): self.current_format = None self.current_device = None return - print 'edit_format_changed' self.clear_fields(new_boxes=True) txt = unicode(txt) fpb = self.current_plugboards.get(txt, None) if fpb is None: - print 'None editable format!' + print 'edit_format_changed: none editable format!' return self.current_format = txt devices = [''] @@ -104,7 +101,6 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): if txt == '': self.current_device = None return - print 'new_device_changed' self.clear_fields(edit_boxes=True) self.current_device = unicode(txt) error = False @@ -142,14 +138,12 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): self.current_format = None self.current_device = None return - print 'new_format_changed' self.clear_fields(edit_boxes=True) self.current_format = unicode(txt) self.new_device.setCurrentIndex(0) def ok_clicked(self): pb = {} - print self.current_format, self.current_device for i in range(0, len(self.source_widgets)): s = self.source_widgets[i].currentIndex() if s != 0: From c852a659220c4984e3ad42309514ce93f2354599 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 27 Sep 2010 17:40:27 +0100 Subject: [PATCH 05/13] Fix two null-plugboard problems --- src/calibre/gui2/device.py | 2 +- src/calibre/library/save_to_disk.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index eb1716f782..72ad8b1890 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -1278,7 +1278,7 @@ class DeviceMixin(object): # {{{ :param files: List of either paths to files or file like objects ''' titles = [i.title for i in metadata] - plugboards = self.library_view.model().db.prefs.get('plugboards', None) + plugboards = self.library_view.model().db.prefs.get('plugboards', {}) job = self.device_manager.upload_books( Dispatcher(self.books_uploaded), files, names, on_card=on_card, diff --git a/src/calibre/library/save_to_disk.py b/src/calibre/library/save_to_disk.py index 54671da4b4..2504832df7 100644 --- a/src/calibre/library/save_to_disk.py +++ b/src/calibre/library/save_to_disk.py @@ -233,7 +233,7 @@ def save_book_to_disk(id, db, root, opts, length): written = False for fmt in formats: dev_name = 'save to disk' - plugboards = db.prefs.get('plugboards', None) + plugboards = db.prefs.get('plugboards', {}) cpb = None if fmt in plugboards: cpb = plugboards[fmt] From 62f1edd84879022bcd89d75fcc70bdfb0d33fed1 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 28 Sep 2010 11:41:35 +0100 Subject: [PATCH 06/13] Cleanups of plugboard code. Improvements to the gui. --- src/calibre/ebooks/metadata/book/base.py | 1 - src/calibre/gui2/device.py | 14 +- src/calibre/gui2/preferences/plugboard.py | 196 +++++++++++++--------- src/calibre/gui2/preferences/plugboard.ui | 80 +++++---- src/calibre/library/save_to_disk.py | 19 ++- 5 files changed, 191 insertions(+), 119 deletions(-) diff --git a/src/calibre/ebooks/metadata/book/base.py b/src/calibre/ebooks/metadata/book/base.py index aaa7c78e9a..951a55da10 100644 --- a/src/calibre/ebooks/metadata/book/base.py +++ b/src/calibre/ebooks/metadata/book/base.py @@ -303,7 +303,6 @@ class Metadata(object): return for src in attrs: try: - print src sfm = other.metadata_for_field(src) dfm = self.metadata_for_field(attrs[src]) if dfm['is_multiple']: diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index 72ad8b1890..4c866b1855 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -34,6 +34,8 @@ from calibre.ebooks.metadata.meta import set_metadata from calibre.constants import DEBUG from calibre.utils.config import prefs, tweaks from calibre.utils.magick.draw import thumbnail +from calibre.library.save_to_disk import plugboard_any_device_value, \ + plugboard_any_format_value # }}} class DeviceJob(BaseJob): # {{{ @@ -323,22 +325,22 @@ class DeviceManager(Thread): # {{{ for f, mi in zip(files, metadata): if isinstance(f, unicode): ext = f.rpartition('.')[-1].lower() - dev_name = self.connected_device.name + dev_name = self.connected_device.__class__.__name__ cpb = None if ext in plugboards: cpb = plugboards[ext] - elif ' any' in plugboards: - cpb = plugboards[' any'] + elif plugboard_any_format_value in plugboards: + cpb = plugboards[plugboard_any_format_value] if cpb is not None: if dev_name in cpb: cpb = cpb[dev_name] - elif ' any' in plugboards[ext]: - cpb = cpb[' any'] + elif plugboard_any_device_value in plugboards[ext]: + cpb = cpb[plugboard_any_device_value] else: cpb = None if DEBUG: - prints('Using plugboard', cpb) + prints('Using plugboard', ext, dev_name, cpb) if ext: try: if DEBUG: diff --git a/src/calibre/gui2/preferences/plugboard.py b/src/calibre/gui2/preferences/plugboard.py index 7fdd093dc1..b723fb938c 100644 --- a/src/calibre/gui2/preferences/plugboard.py +++ b/src/calibre/gui2/preferences/plugboard.py @@ -8,32 +8,84 @@ __docformat__ = 'restructuredtext en' from PyQt4 import QtGui from calibre.gui2 import error_dialog -from calibre.gui2.preferences import ConfigWidgetBase, test_widget, \ - AbortCommit +from calibre.gui2.preferences import ConfigWidgetBase, test_widget from calibre.gui2.preferences.plugboard_ui import Ui_Form from calibre.customize.ui import metadata_writers, device_plugins - +from calibre.library.save_to_disk import plugboard_any_format_value, \ + plugboard_any_device_value, plugboard_save_to_disk_value class ConfigWidget(ConfigWidgetBase, Ui_Form): def genesis(self, gui): self.gui = gui self.db = gui.library_view.model().db - self.current_plugboards = self.db.prefs.get('plugboards', {'epub': {' any': {'title':'authors', 'authors':'tags'}}}) + self.current_plugboards = self.db.prefs.get('plugboards',{}) self.current_device = None self.current_format = None -# self.proxy = ConfigProxy(config()) -# -# r = self.register -# -# for x in ('asciiize', 'update_metadata', 'save_cover', 'write_opf', -# 'replace_whitespace', 'to_lowercase', 'formats', 'timefmt'): -# r(x, self.proxy) -# -# self.save_template.changed_signal.connect(self.changed_signal.emit) + + def initialize(self): + def field_cmp(x, y): + if x.startswith('#'): + if y.startswith('#'): + return cmp(x.lower(), y.lower()) + else: + return 1 + elif y.startswith('#'): + return -1 + else: + return cmp(x.lower(), y.lower()) + + ConfigWidgetBase.initialize(self) + + self.devices = [''] + for device in device_plugins(): + n = device.__class__.__name__ + if n.startswith('FOLDER_DEVICE'): + n = 'FOLDER_DEVICE' + self.devices.append(n) + self.devices.sort(cmp=lambda x, y: cmp(x.lower(), y.lower())) + self.devices.insert(1, plugboard_save_to_disk_value) + self.devices.insert(2, plugboard_any_device_value) + self.new_device.addItems(self.devices) + + self.formats = [''] + for w in metadata_writers(): + for f in w.file_types: + self.formats.append(f) + self.formats.sort() + self.formats.insert(1, plugboard_any_format_value) + self.new_format.addItems(self.formats) + + self.fields = [''] + for f in self.db.all_field_keys(): + if self.db.field_metadata[f].get('rec_index', None) is not None and\ + self.db.field_metadata[f]['datatype'] is not None and \ + self.db.field_metadata[f]['search_terms']: + self.fields.append(f) + self.fields.sort(cmp=field_cmp) + + self.source_widgets = [] + self.dest_widgets = [] + for i in range(0, 10): + w = QtGui.QComboBox(self) + self.source_widgets.append(w) + self.fields_layout.addWidget(w, 5+i, 0, 1, 1) + w = QtGui.QComboBox(self) + self.dest_widgets.append(w) + self.fields_layout.addWidget(w, 5+i, 1, 1, 1) + + self.edit_device.currentIndexChanged[str].connect(self.edit_device_changed) + self.edit_format.currentIndexChanged[str].connect(self.edit_format_changed) + self.new_device.currentIndexChanged[str].connect(self.new_device_changed) + self.new_format.currentIndexChanged[str].connect(self.new_format_changed) + self.ok_button.clicked.connect(self.ok_clicked) + self.del_button.clicked.connect(self.del_clicked) + + self.refill_all_boxes() def clear_fields(self, edit_boxes=False, new_boxes=False): self.ok_button.setEnabled(False) + self.del_button.setEnabled(False) for w in self.source_widgets: w.clear() for w in self.dest_widgets: @@ -47,6 +99,7 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): def set_fields(self): self.ok_button.setEnabled(True) + self.del_button.setEnabled(True) for w in self.source_widgets: w.addItems(self.fields) for w in self.dest_widgets: @@ -76,6 +129,7 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): for i,src in enumerate(dpb): self.set_field(i, src, dpb[src]) self.ok_button.setEnabled(True) + self.del_button.setEnabled(True) def edit_format_changed(self, txt): if txt == '': @@ -104,26 +158,42 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): self.clear_fields(edit_boxes=True) self.current_device = unicode(txt) error = False - if self.current_format == ' any': + if self.current_format == plugboard_any_format_value: + # user specified any format. for f in self.current_plugboards: - if self.current_device == ' any' and len(self.current_plugboards[f]): + devs = set(self.current_plugboards[f]) + print 'check', self.current_format, devs + if self.current_device != plugboard_save_to_disk_value and \ + plugboard_any_device_value in devs: + # specific format/any device in list. conflict. + # note: any device does not match save_to_disk error = True break - if self.current_device in self.current_plugboards[f]: + if self.current_device in devs: + # specific format/current device in list. conflict error = True break - if ' any' in self.current_plugboards[f]: + if self.current_device == plugboard_any_device_value: + # any device and a specific device already there. conflict error = True break else: - fpb = self.current_plugboards.get(self.current_format, None) - if fpb is not None: - if ' any' in fpb: + # user specified specific format. + for f in self.current_plugboards: + devs = set(self.current_plugboards[f]) + if f == plugboard_any_format_value and \ + self.current_device in devs: + # any format/same device in list. conflict. error = True - else: - dpb = fpb.get(self.current_device, None) - if dpb is not None: - error = True + break + if f == self.current_format and self.current_device in devs: + # current format/current device in list. conflict + error = True + break + if f == self.current_format and plugboard_any_device_value in devs: + # current format/any device in list. conflict + error = True + break if error: error_dialog(self, '', @@ -165,6 +235,16 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): self.changed_signal.emit() self.refill_all_boxes() + def del_clicked(self): + if self.current_format in self.current_plugboards: + fpb = self.current_plugboards[self.current_format] + if self.current_device in fpb: + del fpb[self.current_device] + if len(fpb) == 0: + del self.current_plugboards[self.current_format] + self.changed_signal.emit() + self.refill_all_boxes() + def refill_all_boxes(self): self.current_device = None self.current_format = None @@ -176,59 +256,21 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): self.edit_format.setCurrentIndex(0) self.edit_device.clear() self.ok_button.setEnabled(False) - - def initialize(self): - def field_cmp(x, y): - if x.startswith('#'): - if y.startswith('#'): - return cmp(x.lower(), y.lower()) - else: - return 1 - elif y.startswith('#'): - return -1 - else: - return cmp(x.lower(), y.lower()) - - ConfigWidgetBase.initialize(self) - - self.devices = ['', ' any', 'save to disk'] - for device in device_plugins(): - self.devices.append(device.name) - self.devices.sort(cmp=lambda x, y: cmp(x.lower(), y.lower())) - self.new_device.addItems(self.devices) - - self.formats = ['', ' any'] - for w in metadata_writers(): - for f in w.file_types: - self.formats.append(f) - self.formats.sort() - self.new_format.addItems(self.formats) - - self.fields = [''] - for f in self.db.all_field_keys(): - if self.db.field_metadata[f].get('rec_index', None) is not None and\ - self.db.field_metadata[f]['datatype'] is not None and \ - self.db.field_metadata[f]['search_terms']: - self.fields.append(f) - self.fields.sort(cmp=field_cmp) - - self.source_widgets = [] - self.dest_widgets = [] - for i in range(0, 10): - w = QtGui.QComboBox(self) - self.source_widgets.append(w) - self.fields_layout.addWidget(w, 5+i, 0, 1, 1) - w = QtGui.QComboBox(self) - self.dest_widgets.append(w) - self.fields_layout.addWidget(w, 5+i, 1, 1, 1) - - self.edit_device.currentIndexChanged[str].connect(self.edit_device_changed) - self.edit_format.currentIndexChanged[str].connect(self.edit_format_changed) - self.new_device.currentIndexChanged[str].connect(self.new_device_changed) - self.new_format.currentIndexChanged[str].connect(self.new_format_changed) - self.ok_button.clicked.connect(self.ok_clicked) - - self.refill_all_boxes() + self.del_button.setEnabled(False) + txt = '' + for f in self.formats: + if f not in self.current_plugboards: + continue + for d in self.devices: + if d not in self.current_plugboards[f]: + continue + ops = [] + for op in self.fields: + if op not in self.current_plugboards[f][d]: + continue + ops.append(op + '->' + self.current_plugboards[f][d][op]) + txt += '%s:%s [%s]\n'%(f, d, ', '.join(ops)) + self.existing_plugboards.setPlainText(txt) def restore_defaults(self): ConfigWidgetBase.restore_defaults(self) diff --git a/src/calibre/gui2/preferences/plugboard.ui b/src/calibre/gui2/preferences/plugboard.ui index ad72ec359f..f88af8ff50 100644 --- a/src/calibre/gui2/preferences/plugboard.ui +++ b/src/calibre/gui2/preferences/plugboard.ui @@ -26,32 +26,6 @@ - - - - Add new plugboard - - - - - - - Edit existing plugboard - - - - - - - - - - - - - - - @@ -72,7 +46,50 @@ + + + + Add new plugboard + + + + + + + + + + + + + Edit existing plugboard + + + + + + + + + + + + Existing plugboards + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + QPlainTextEdit::NoWrap + + + + Qt::Vertical @@ -122,10 +139,17 @@ - + - Done + Save + + + + + + + Delete diff --git a/src/calibre/library/save_to_disk.py b/src/calibre/library/save_to_disk.py index 2504832df7..5465150797 100644 --- a/src/calibre/library/save_to_disk.py +++ b/src/calibre/library/save_to_disk.py @@ -17,7 +17,12 @@ from calibre.ebooks.metadata.meta import set_metadata from calibre.constants import preferred_encoding, filesystem_encoding from calibre.ebooks.metadata import fmt_sidx from calibre.ebooks.metadata import title_sort -from calibre import strftime +from calibre import strftime, prints + +plugboard_any_device_value = 'any device' +plugboard_any_format_value = 'any format' +plugboard_save_to_disk_value = 'save_to_disk' + DEFAULT_TEMPLATE = '{author_sort}/{title}/{title} - {authors}' DEFAULT_SEND_TEMPLATE = '{author_sort}/{title} - {authors}' @@ -232,21 +237,21 @@ def save_book_to_disk(id, db, root, opts, length): written = False for fmt in formats: - dev_name = 'save to disk' + global plugboard_save_to_disk_value, plugboard_any_format_value + dev_name = plugboard_save_to_disk_value plugboards = db.prefs.get('plugboards', {}) cpb = None if fmt in plugboards: cpb = plugboards[fmt] - elif ' any' in plugboards: - cpb = plugboards[' any'] + elif plugboard_any_format_value in plugboards: + cpb = plugboards[plugboard_any_format_value] + # must find a save_to_disk entry for this format if cpb is not None: if dev_name in cpb: cpb = cpb[dev_name] - elif ' any' in plugboards[fmt]: - cpb = cpb[' any'] else: cpb = None - + prints('Using plugboard:', fmt, cpb) data = db.format(id, fmt, index_is_id=True) if data is None: continue From e08da942ec7a1c12db488c0e49bd6cb0c61aef6b Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 28 Sep 2010 11:46:53 +0100 Subject: [PATCH 07/13] Fix typo in faq.rst (too -> to) --- src/calibre/manual/faq.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/manual/faq.rst b/src/calibre/manual/faq.rst index c9f6abe2c0..3cf171bc1b 100644 --- a/src/calibre/manual/faq.rst +++ b/src/calibre/manual/faq.rst @@ -289,7 +289,7 @@ Yes, you can. Follow the instructions in the answer above for adding custom colu How do I move my |app| library from one computer to another? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Simply copy the |app| library folder from the old to the new computer. You can find out what the library folder is by clicking the calibre icon in the toolbar. The very first item is the path to the library folder. Now on the new computer, start |app| for the first time. It will run the Welcome Wizard asking you for the location of the |app| library. Point it to the previously copied folder. If the computer you are transferring too already has a calibre installation, then the Welcome wizard wont run. In that case, click the calibre icon in the tooolbar and point it to the newly copied directory. You will now have two calibre libraries on your computer and you can switch between them by clicking the calibre icon on the toolbar. +Simply copy the |app| library folder from the old to the new computer. You can find out what the library folder is by clicking the calibre icon in the toolbar. The very first item is the path to the library folder. Now on the new computer, start |app| for the first time. It will run the Welcome Wizard asking you for the location of the |app| library. Point it to the previously copied folder. If the computer you are transferring to already has a calibre installation, then the Welcome wizard wont run. In that case, click the calibre icon in the tooolbar and point it to the newly copied directory. You will now have two calibre libraries on your computer and you can switch between them by clicking the calibre icon on the toolbar. Note that if you are transferring between different types of computers (for example Windows to OS X) then after doing the above you should also go to :guilabel:`Preferences->Advanced->Miscellaneous` and click the "Check database integrity button". It will warn you about missing files, if any, which you should then transfer by hand. From cca39d2e730a2877a753747ba2b4fd93a9b6384f Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 28 Sep 2010 13:11:55 +0100 Subject: [PATCH 08/13] Small cleanups for messages and name --- src/calibre/customize/builtins.py | 2 +- src/calibre/gui2/preferences/plugboard.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py index 89c800afb2..cf6995d3bb 100644 --- a/src/calibre/customize/builtins.py +++ b/src/calibre/customize/builtins.py @@ -799,7 +799,7 @@ class Sending(PreferencesPlugin): class Plugboard(PreferencesPlugin): name = 'Plugboard' icon = I('plugboard.png') - gui_name = _('Metadata plugboard') + gui_name = _('Metadata plugboards') category = 'Import/Export' gui_category = _('Import/Export') category_order = 3 diff --git a/src/calibre/gui2/preferences/plugboard.py b/src/calibre/gui2/preferences/plugboard.py index b723fb938c..124654b643 100644 --- a/src/calibre/gui2/preferences/plugboard.py +++ b/src/calibre/gui2/preferences/plugboard.py @@ -197,7 +197,8 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): if error: error_dialog(self, '', - _('That format and device already has a plugboard'), + _('That format and device already has a plugboard or ' + 'conflicts with another plugboard.'), show=True) self.new_device.setCurrentIndex(0) return From 8a94c2194eada809060d3918d501f7029c0947ff Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 28 Sep 2010 15:13:30 +0100 Subject: [PATCH 09/13] Fix mutually recursive fields in save_to_disk. Fix mistake in any_format template handling in save_to_disk --- src/calibre/library/save_to_disk.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/calibre/library/save_to_disk.py b/src/calibre/library/save_to_disk.py index 5465150797..a2c8a62694 100644 --- a/src/calibre/library/save_to_disk.py +++ b/src/calibre/library/save_to_disk.py @@ -111,18 +111,31 @@ class SafeFormat(TemplateFormatter): ''' Provides a format function that substitutes '' for any missing value ''' + + composite_values = {} + def get_value(self, key, args, kwargs): try: b = self.book.get_user_metadata(key, False) key = key.lower() if b is not None and b['datatype'] == 'composite': - return self.vformat(b['display']['composite_template'], [], kwargs) + if key in self.composite_values: + return self.composite_values[key] + self.composite_values[key] = 'RECURSIVE_COMPOSITE FIELD (S2D) ' + key + self.composite_values[key] = \ + self.vformat(b['display']['composite_template'], [], kwargs) + return self.composite_values[key] if kwargs[key]: return self.sanitize(kwargs[key.lower()]) return '' except: return '' + def safe_format(self, fmt, kwargs, error_value, book, sanitize=None): + self.composite_values = {} + return TemplateFormatter.safe_format(self, fmt, kwargs, error_value, + book, sanitize) + safe_formatter = SafeFormat() def get_components(template, mi, id, timefmt='%b %Y', length=250, @@ -243,10 +256,12 @@ def save_book_to_disk(id, db, root, opts, length): cpb = None if fmt in plugboards: cpb = plugboards[fmt] - elif plugboard_any_format_value in plugboards: + if dev_name in cpb: + cpb = cpb[dev_name] + else: + cpb = None + if cpb is None and plugboard_any_format_value in plugboards: cpb = plugboards[plugboard_any_format_value] - # must find a save_to_disk entry for this format - if cpb is not None: if dev_name in cpb: cpb = cpb[dev_name] else: From 700dbe7df7fb8b43b6392870aaaec900f0a234e8 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 29 Sep 2010 12:51:18 +0100 Subject: [PATCH 10/13] 1) add dirtied when renaming items 2) make bulk edit use the GUI thread 3) add a 'books remaiing' menu item --- src/calibre/gui2/actions/choose_library.py | 21 +- src/calibre/gui2/dialogs/metadata_bulk.py | 241 +++++++++++++-------- src/calibre/library/caches.py | 38 ++-- src/calibre/library/custom_columns.py | 2 + src/calibre/library/database2.py | 48 +++- 5 files changed, 236 insertions(+), 114 deletions(-) diff --git a/src/calibre/gui2/actions/choose_library.py b/src/calibre/gui2/actions/choose_library.py index 79406da40c..d3045fecf4 100644 --- a/src/calibre/gui2/actions/choose_library.py +++ b/src/calibre/gui2/actions/choose_library.py @@ -14,7 +14,7 @@ from calibre import isbytestring from calibre.constants import filesystem_encoding from calibre.utils.config import prefs from calibre.gui2 import gprefs, warning_dialog, Dispatcher, error_dialog, \ - question_dialog + question_dialog, info_dialog from calibre.gui2.actions import InterfaceAction class LibraryUsageStats(object): @@ -115,6 +115,14 @@ class ChooseLibraryAction(InterfaceAction): type=Qt.QueuedConnection) self.choose_menu.addAction(ac) + self.rename_separator = self.choose_menu.addSeparator() + + self.create_action(spec=(_('Library backup status...'), 'lt.png', None, + None), attr='action_backup_status') + self.action_backup_status.triggered.connect(self.backup_status, + type=Qt.QueuedConnection) + self.choose_menu.addAction(self.action_backup_status) + def library_name(self): db = self.gui.library_view.model().db path = db.library_path @@ -206,6 +214,17 @@ class ChooseLibraryAction(InterfaceAction): self.stats.remove(location) self.build_menus() + def backup_status(self, location): + dirty_text = 'no' + try: + print 'here' + dirty_text = \ + unicode(self.gui.library_view.model().db.dirty_queue_length()) + except: + dirty_text = _('none') + info_dialog(self.gui, _('Backup status'), '

'+ + _('Book metadata files remaining to be written: %s') % dirty_text, + show=True) def switch_requested(self, location): if not self.change_library_allowed(): diff --git a/src/calibre/gui2/dialogs/metadata_bulk.py b/src/calibre/gui2/dialogs/metadata_bulk.py index b0ce0a1e6d..4fc85f2b30 100644 --- a/src/calibre/gui2/dialogs/metadata_bulk.py +++ b/src/calibre/gui2/dialogs/metadata_bulk.py @@ -3,42 +3,109 @@ __copyright__ = '2008, Kovid Goyal ' '''Dialog to edit metadata in bulk''' -from threading import Thread -import re, string +import re -from PyQt4.Qt import Qt, QDialog, QGridLayout +from PyQt4.Qt import Qt, QDialog, QGridLayout, QVBoxLayout, QFont, QLabel, \ + pyqtSignal from PyQt4 import QtGui from calibre.gui2.dialogs.metadata_bulk_ui import Ui_MetadataBulkDialog from calibre.gui2.dialogs.tag_editor import TagEditor from calibre.ebooks.metadata import string_to_authors, authors_to_string from calibre.gui2.custom_column_widgets import populate_metadata_page -from calibre.gui2.dialogs.progress import BlockingBusy -from calibre.gui2 import error_dialog, Dispatcher +from calibre.gui2 import error_dialog +from calibre.gui2.progress_indicator import ProgressIndicator from calibre.utils.config import dynamic -class Worker(Thread): +class MyBlockingBusy(QDialog): + + do_one_signal = pyqtSignal() + + phases = ['', + _('Title/Author'), + _('Standard metadata'), + _('Custom metadata'), + _('Search/Replace'), + ] + + def __init__(self, msg, args, db, ids, cc_widgets, s_r_func, + parent=None, window_title=_('Working')): + QDialog.__init__(self, parent) + + self._layout = QVBoxLayout() + self.setLayout(self._layout) + self.msg_text = msg + self.msg = QLabel(msg+' ') # Ensure dialog is wide enough + #self.msg.setWordWrap(True) + self.font = QFont() + self.font.setPointSize(self.font.pointSize() + 8) + self.msg.setFont(self.font) + self.pi = ProgressIndicator(self) + self.pi.setDisplaySize(100) + self._layout.addWidget(self.pi, 0, Qt.AlignHCenter) + self._layout.addSpacing(15) + self._layout.addWidget(self.msg, 0, Qt.AlignHCenter) + self.setWindowTitle(window_title) + self.resize(self.sizeHint()) + self.start() - def __init__(self, args, db, ids, cc_widgets, callback): - Thread.__init__(self) self.args = args self.db = db self.ids = ids self.error = None - self.callback = callback self.cc_widgets = cc_widgets + self.s_r_func = s_r_func + self.do_one_signal.connect(self.do_one_safe, Qt.QueuedConnection) - def doit(self): + def start(self): + self.pi.startAnimation() + + def stop(self): + self.pi.stopAnimation() + + def accept(self): + self.stop() + return QDialog.accept(self) + + def exec_(self): + self.current_index = 0 + self.current_phase = 1 + self.do_one_signal.emit() + return QDialog.exec_(self) + + def do_one_safe(self): + try: + if self.current_index >= len(self.ids): + self.current_phase += 1 + self.current_index = 0 + if self.current_phase > 4: + self.db.commit() + return self.accept() + id = self.ids[self.current_index] + self.msg.setText(self.msg_text.format(self.phases[self.current_phase], + (self.current_index*100)/len(self.ids))) + self.do_one(id) + except Exception, err: + import traceback + try: + err = unicode(err) + except: + err = repr(err) + self.error = (err, traceback.format_exc()) + return self.accept() + + def do_one(self, id): remove, add, au, aus, do_aus, rating, pub, do_series, \ do_autonumber, do_remove_format, remove_format, do_swap_ta, \ do_remove_conv, do_auto_author, series, do_series_restart, \ series_start_value, do_title_case, clear_series = self.args + # first loop: do author and title. These will commit at the end of each # operation, because each operation modifies the file system. We want to # try hard to keep the DB and the file system in sync, even in the face # of exceptions or forced exits. - for id in self.ids: + if self.current_phase == 1: title_set = False if do_swap_ta: title = self.db.title(id, index_is_id=True) @@ -58,9 +125,8 @@ class Worker(Thread): self.db.set_title(id, title.title(), notify=False) if au: self.db.set_authors(id, string_to_authors(au), notify=False) - - # All of these just affect the DB, so we can tolerate a total rollback - for id in self.ids: + elif self.current_phase == 2: + # All of these just affect the DB, so we can tolerate a total rollback if do_auto_author: x = self.db.author_sort_from_book(id, index_is_id=True) if x: @@ -93,37 +159,19 @@ class Worker(Thread): if do_remove_conv: self.db.delete_conversion_options(id, 'PIPE', commit=False) - self.db.commit() + elif self.current_phase == 3: + # both of these are fast enough to just do them all + for w in self.cc_widgets: + w.commit(self.ids) + self.db.bulk_modify_tags(self.ids, add=add, remove=remove, + notify=False) + self.current_index = len(self.ids) + elif self.current_phase == 4: + self.s_r_func(id) + # do the next one + self.current_index += 1 + self.do_one_signal.emit() - for w in self.cc_widgets: - w.commit(self.ids) - self.db.bulk_modify_tags(self.ids, add=add, remove=remove, - notify=False) - - def run(self): - try: - self.doit() - except Exception, err: - import traceback - try: - err = unicode(err) - except: - err = repr(err) - self.error = (err, traceback.format_exc()) - - self.callback() - -class SafeFormat(string.Formatter): - ''' - Provides a format function that substitutes '' for any missing value - ''' - def get_value(self, key, args, vals): - v = vals.get(key, None) - if v is None: - return '' - if isinstance(v, (tuple, list)): - v = ','.join(v) - return v class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog): @@ -452,7 +500,7 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog): self.s_r_set_colors() break - def do_search_replace(self): + def do_search_replace(self, id): source = unicode(self.search_field.currentText()) if not source or not self.s_r_obj: return @@ -461,48 +509,45 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog): dest = source dfm = self.db.field_metadata[dest] - for id in self.ids: - mi = self.db.get_metadata(id, index_is_id=True,) - val = mi.get(source) - if val is None: - continue - val = self.s_r_do_regexp(mi) - val = self.s_r_do_destination(mi, val) - if dfm['is_multiple']: - if dfm['is_custom']: - # The standard tags and authors values want to be lists. - # All custom columns are to be strings - val = dfm['is_multiple'].join(val) - if dest == 'authors' and len(val) == 0: - error_dialog(self, _('Search/replace invalid'), - _('Authors cannot be set to the empty string. ' - 'Book title %s not processed')%mi.title, - show=True) - continue - else: - val = self.s_r_replace_mode_separator().join(val) - if dest == 'title' and len(val) == 0: - error_dialog(self, _('Search/replace invalid'), - _('Title cannot be set to the empty string. ' - 'Book title %s not processed')%mi.title, - show=True) - continue - + mi = self.db.get_metadata(id, index_is_id=True,) + val = mi.get(source) + if val is None: + return + val = self.s_r_do_regexp(mi) + val = self.s_r_do_destination(mi, val) + if dfm['is_multiple']: if dfm['is_custom']: - extra = self.db.get_custom_extra(id, label=dfm['label'], index_is_id=True) - self.db.set_custom(id, val, label=dfm['label'], extra=extra, - commit=False) + # The standard tags and authors values want to be lists. + # All custom columns are to be strings + val = dfm['is_multiple'].join(val) + if dest == 'authors' and len(val) == 0: + error_dialog(self, _('Search/replace invalid'), + _('Authors cannot be set to the empty string. ' + 'Book title %s not processed')%mi.title, + show=True) + return + else: + val = self.s_r_replace_mode_separator().join(val) + if dest == 'title' and len(val) == 0: + error_dialog(self, _('Search/replace invalid'), + _('Title cannot be set to the empty string. ' + 'Book title %s not processed')%mi.title, + show=True) + return + + if dfm['is_custom']: + extra = self.db.get_custom_extra(id, label=dfm['label'], index_is_id=True) + self.db.set_custom(id, val, label=dfm['label'], extra=extra, + commit=False) + else: + if dest == 'comments': + setter = self.db.set_comment else: - if dest == 'comments': - setter = self.db.set_comment - else: - setter = getattr(self.db, 'set_'+dest) - if dest in ['title', 'authors']: - setter(id, val, notify=False) - else: - setter(id, val, notify=False, commit=False) - self.db.commit() - dynamic['s_r_search_mode'] = self.search_mode.currentIndex() + setter = getattr(self.db, 'set_'+dest) + if dest in ['title', 'authors']: + setter(id, val, notify=False) + else: + setter(id, val, notify=False, commit=False) def create_custom_column_editors(self): w = self.central_widget.widget(1) @@ -525,11 +570,11 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog): def initalize_authors(self): all_authors = self.db.all_authors() - all_authors.sort(cmp=lambda x, y : cmp(x[1], y[1])) + all_authors.sort(cmp=lambda x, y : cmp(x[1].lower(), y[1].lower())) for i in all_authors: id, name = i - name = authors_to_string([name.strip().replace('|', ',') for n in name.split(',')]) + name = name.strip().replace('|', ',') self.authors.addItem(name) self.authors.setEditText('') @@ -613,28 +658,32 @@ class MetadataBulkDialog(QDialog, Ui_MetadataBulkDialog): do_remove_conv, do_auto_author, series, do_series_restart, series_start_value, do_title_case, clear_series) - bb = BlockingBusy(_('Applying changes to %d books. This may take a while.') - %len(self.ids), parent=self) - self.worker = Worker(args, self.db, self.ids, +# bb = BlockingBusy(_('Applying changes to %d books. This may take a while.') +# %len(self.ids), parent=self) +# self.worker = Worker(args, self.db, self.ids, +# getattr(self, 'custom_column_widgets', []), +# Dispatcher(bb.accept, parent=bb)) + + bb = MyBlockingBusy(_('Applying changes to %d books.\nPhase {0} {1}%%.') + %len(self.ids), args, self.db, self.ids, getattr(self, 'custom_column_widgets', []), - Dispatcher(bb.accept, parent=bb)) + self.do_search_replace, parent=self) # The metadata backup thread causes database commits # which can slow down bulk editing of large numbers of books self.model.stop_metadata_backup() try: - self.worker.start() +# self.worker.start() bb.exec_() finally: self.model.start_metadata_backup() - if self.worker.error is not None: + if bb.error is not None: return error_dialog(self, _('Failed'), - self.worker.error[0], det_msg=self.worker.error[1], + bb.error[0], det_msg=bb.error[1], show=True) - self.do_search_replace() - + dynamic['s_r_search_mode'] = self.search_mode.currentIndex() self.db.clean() return QDialog.accept(self) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index 281d1485b7..a36dbe57a9 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -138,25 +138,37 @@ class CoverCache(Thread): # {{{ def run(self): while self.keep_running: try: - time.sleep(0.050) # Limit 20/second to not overwhelm the GUI + # The GUI puts the same ID into the queue many times. The code + # below emptys the queue, building a set of unique values. When + # the queue is empty, do the work + ids = set() id_ = self.load_queue.get(True, 2) + ids.add(id_) + try: + while True: + # Give the gui some time to put values into the queue + id_ = self.load_queue.get(True, 0.5) + ids.add(id_) + except Empty: + pass except Empty: continue except: #Happens during interpreter shutdown break - try: - img = self._image_for_id(id_) - except: - import traceback - traceback.print_exc() - continue - try: - with self.lock: - self.cache[id_] = img - except: - # Happens during interpreter shutdown - break + for id_ in ids: + time.sleep(0.050) # Limit 20/second to not overwhelm the GUI + try: + img = self._image_for_id(id_) + except: + traceback.print_exc() + continue + try: + with self.lock: + self.cache[id_] = img + except: + # Happens during interpreter shutdown + break def set_cache(self, ids): with self.lock: diff --git a/src/calibre/library/custom_columns.py b/src/calibre/library/custom_columns.py index 97c8565177..fdd78e89f8 100644 --- a/src/calibre/library/custom_columns.py +++ b/src/calibre/library/custom_columns.py @@ -214,6 +214,7 @@ class CustomColumns(object): 'SELECT id FROM %s WHERE value=?'%table, (new_name,), all=False) if new_id is None or old_id == new_id: self.conn.execute('UPDATE %s SET value=? WHERE id=?'%table, (new_name, old_id)) + new_id = old_id else: # New id exists. If the column is_multiple, then process like # tags, otherwise process like publishers (see database2) @@ -226,6 +227,7 @@ class CustomColumns(object): self.conn.execute('''UPDATE %s SET value=? WHERE value=?'''%lt, (new_id, old_id,)) self.conn.execute('DELETE FROM %s WHERE id=?'%table, (old_id,)) + self.dirty_books_referencing('#'+data['label'], new_id, commit=False) self.conn.commit() def delete_custom_item_using_id(self, id, label=None, num=None): diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 192de21df3..85fb955448 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -47,13 +47,21 @@ def delete_file(path): def delete_tree(path, permanent=False): if permanent: - shutil.rmtree(path) + try: + # For completely mysterious reasons, sometimes a file is left open + # leading to access errors. If we get an exception, wait and hope + # that whatever has the file (the O/S?) lets go of it. + shutil.rmtree(path) + except: + traceback.print_exc() + time.sleep(1) + shutil.rmtree(path) else: try: if not permanent: winshell.delete_file(path, silent=True, no_confirm=True) except: - shutil.rmtree(path) + delete_tree(path, permanent=True) copyfile = os.link if hasattr(os, 'link') else shutil.copyfile @@ -520,6 +528,11 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): try: f = open(path, 'rb') except (IOError, OSError): + try: + f.close() + print 'cover exception left file open!', path + except: + pass time.sleep(0.2) f = open(path, 'rb') if as_image: @@ -627,6 +640,9 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): if commit: self.conn.commit() + def dirty_queue_length(self): + return len(self.dirtied_cache) + def commit_dirty_cache(self): ''' Set the dirty indication for every book in the cache. The vast majority @@ -1286,7 +1302,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): val=mi.get(key), extra=mi.get_extra(key), label=user_mi[key]['label'], commit=False) - self.commit() + self.conn.commit() self.notify('metadata', [id]) def authors_sort_strings(self, id, index_is_id=False): @@ -1444,6 +1460,19 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): # Convenience methods for tags_list_editor # Note: we generally do not need to refresh_ids because library_view will # refresh everything. + + def dirty_books_referencing(self, field, id, commit=True): + # Get the list of books to dirty -- all books that reference the item + table = self.field_metadata[field]['table'] + link = self.field_metadata[field]['link_column'] + bks = self.conn.get( + 'SELECT book from books_{0}_link WHERE {1}=?'.format(table, link), + (id,)) + books = [] + for (book_id,) in bks: + books.append(book_id) + self.dirtied(books, commit=commit) + def get_tags_with_ids(self): result = self.conn.get('SELECT id,name FROM tags') if not result: @@ -1460,6 +1489,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): # there is a change of case self.conn.execute('''UPDATE tags SET name=? WHERE id=?''', (new_name, old_id)) + self.dirty_books_referencing('tags', new_id, commit=False) + new_id = old_id else: # It is possible that by renaming a tag, the tag will appear # twice on a book. This will throw an integrity error, aborting @@ -1477,9 +1508,11 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): WHERE tag=?''',(new_id, old_id,)) # Get rid of the no-longer used publisher self.conn.execute('DELETE FROM tags WHERE id=?', (old_id,)) + self.dirty_books_referencing('tags', new_id, commit=False) self.conn.commit() def delete_tag_using_id(self, id): + self.dirty_books_referencing('tags', id, commit=False) self.conn.execute('DELETE FROM books_tags_link WHERE tag=?', (id,)) self.conn.execute('DELETE FROM tags WHERE id=?', (id,)) self.conn.commit() @@ -1496,6 +1529,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): '''SELECT id from series WHERE name=?''', (new_name,), all=False) if new_id is None or old_id == new_id: + new_id = old_id self.conn.execute('UPDATE series SET name=? WHERE id=?', (new_name, old_id)) else: @@ -1519,15 +1553,17 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): SET series_index=? WHERE id=?''',(index, book_id,)) index = index + 1 + self.dirty_books_referencing('series', new_id, commit=False) self.conn.commit() def delete_series_using_id(self, id): + self.dirty_books_referencing('series', id, commit=False) books = self.conn.get('SELECT book from books_series_link WHERE series=?', (id,)) self.conn.execute('DELETE FROM books_series_link WHERE series=?', (id,)) self.conn.execute('DELETE FROM series WHERE id=?', (id,)) - self.conn.commit() for (book_id,) in books: self.conn.execute('UPDATE books SET series_index=1.0 WHERE id=?', (book_id,)) + self.conn.commit() def get_publishers_with_ids(self): result = self.conn.get('SELECT id,name FROM publishers') @@ -1541,6 +1577,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): '''SELECT id from publishers WHERE name=?''', (new_name,), all=False) if new_id is None or old_id == new_id: + new_id = old_id # New name doesn't exist. Simply change the old name self.conn.execute('UPDATE publishers SET name=? WHERE id=?', \ (new_name, old_id)) @@ -1551,9 +1588,11 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): WHERE publisher=?''',(new_id, old_id,)) # Get rid of the no-longer used publisher self.conn.execute('DELETE FROM publishers WHERE id=?', (old_id,)) + self.dirty_books_referencing('publisher', new_id, commit=False) self.conn.commit() def delete_publisher_using_id(self, old_id): + self.dirty_books_referencing('publisher', id, commit=False) self.conn.execute('''DELETE FROM books_publishers_link WHERE publisher=?''', (old_id,)) self.conn.execute('DELETE FROM publishers WHERE id=?', (old_id,)) @@ -1634,6 +1673,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): # Now delete the old author from the DB bks = self.conn.get('SELECT book FROM books_authors_link WHERE author=?', (old_id,)) self.conn.execute('DELETE FROM authors WHERE id=?', (old_id,)) + self.dirtied(books, commit=False) self.conn.commit() # the authors are now changed, either by changing the author's name # or replacing the author in the list. Now must fix up the books. From 5aadbb2dcd311272f9230b1b9149ed4833c6ff47 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 29 Sep 2010 14:33:11 +0100 Subject: [PATCH 11/13] 1) change plugboards to templates (pass one) 2) fix recursion detection in base.py 3) fix lack of refresh in model when editing custom fields on the GUI 4) change the name of the plugboard eval function in base.py 5) move recursion detection base code to formatter --- src/calibre/ebooks/metadata/book/base.py | 55 +++++++++++------------ src/calibre/gui2/device.py | 2 +- src/calibre/gui2/library/models.py | 4 +- src/calibre/gui2/preferences/plugboard.py | 36 +++++++-------- src/calibre/gui2/preferences/plugboard.ui | 5 ++- src/calibre/library/caches.py | 4 +- src/calibre/library/save_to_disk.py | 9 +--- src/calibre/utils/formatter.py | 5 +++ 8 files changed, 59 insertions(+), 61 deletions(-) diff --git a/src/calibre/ebooks/metadata/book/base.py b/src/calibre/ebooks/metadata/book/base.py index 951a55da10..56df573cee 100644 --- a/src/calibre/ebooks/metadata/book/base.py +++ b/src/calibre/ebooks/metadata/book/base.py @@ -37,7 +37,13 @@ class SafeFormat(TemplateFormatter): def get_value(self, key, args, kwargs): try: - ign, v = self.book.format_field(key.lower(), series_with_index=False) + b = self.book.get_user_metadata(key, False) + if b and b['datatype'] == 'int' and self.book.get(key, 0) == 0: + v = '' + elif b and b['datatype'] == 'float' and b.get(key, 0.0) == 0.0: + v = '' + else: + ign, v = self.book.format_field(key.lower(), series_with_index=False) if v is None: return '' if v == '': @@ -65,7 +71,6 @@ class Metadata(object): ''' _data = copy.deepcopy(NULL_VALUES) object.__setattr__(self, '_data', _data) - _data['_curseq'] = _data['_compseq'] = 0 if other is not None: self.smart_update(other) else: @@ -94,29 +99,22 @@ class Metadata(object): if field in _data['user_metadata'].iterkeys(): d = _data['user_metadata'][field] val = d['#value#'] - if d['datatype'] != 'composite' or \ - (_data['_curseq'] == _data['_compseq'] and val is not None): + if d['datatype'] != 'composite': return val - # Data in the structure has changed. Recompute the composite fields - _data['_compseq'] = _data['_curseq'] - for ck in _data['user_metadata']: - cf = _data['user_metadata'][ck] - if cf['datatype'] != 'composite': - continue - cf['#value#'] = 'RECURSIVE_COMPOSITE FIELD ' + field - cf['#value#'] = composite_formatter.safe_format( - cf['display']['composite_template'], + if val is None: + d['#value#'] = 'RECURSIVE_COMPOSITE FIELD (Metadata) ' + field + val = d['#value#'] = composite_formatter.safe_format( + d['display']['composite_template'], self, _('TEMPLATE ERROR'), self).strip() - return d['#value#'] + return val raise AttributeError( 'Metadata object has no attribute named: '+ repr(field)) def __setattr__(self, field, val, extra=None): _data = object.__getattribute__(self, '_data') - _data['_curseq'] += 1 if field in TOP_LEVEL_CLASSIFIERS: _data['classifiers'].update({field: val}) elif field in STANDARD_METADATA_FIELDS: @@ -124,7 +122,10 @@ class Metadata(object): val = NULL_VALUES.get(field, None) _data[field] = val elif field in _data['user_metadata'].iterkeys(): - _data['user_metadata'][field]['#value#'] = val + if _data['user_metadata'][field]['datatype'] == 'composite': + _data['user_metadata'][field]['#value#'] = None + else: + _data['user_metadata'][field]['#value#'] = val _data['user_metadata'][field]['#extra#'] = extra else: # You are allowed to stick arbitrary attributes onto this object as @@ -294,28 +295,24 @@ class Metadata(object): _data = object.__getattribute__(self, '_data') _data['user_metadata'][field] = metadata - def copy_specific_attributes(self, other, attrs): + def template_to_attribute(self, other, attrs): ''' - Takes a dict {src:dest, src:dest} and copys other[src] to self[dest]. - This is on a best-efforts basis. Some assignments can make no sense. + Takes a dict {src:dest, src:dest}, evaluates the template in the context + of other, then copies the result to self[dest]. This is on a best- + efforts basis. Some assignments can make no sense. ''' if not attrs: return for src in attrs: try: - sfm = other.metadata_for_field(src) + val = composite_formatter.safe_format\ + (src, other, 'PLUGBOARD TEMPLATE ERROR', other) dfm = self.metadata_for_field(attrs[src]) if dfm['is_multiple']: - if sfm['is_multiple']: - self.set(attrs[src], other.get(src)) - else: - self.set(attrs[src], - [f.strip() for f in other.get(src).split(',') - if f.strip()]) - elif sfm['is_multiple']: - self.set(attrs[src], ','.join(other.get(src))) + self.set(attrs[src], + [f.strip() for f in val.split(',') if f.strip()]) else: - self.set(attrs[src], other.get(src)) + self.set(attrs[src], val) except: traceback.print_exc() pass diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index 4c866b1855..3da4fddb5d 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -349,7 +349,7 @@ class DeviceManager(Thread): # {{{ with open(f, 'r+b') as stream: if cpb: newmi = mi.deepcopy() - newmi.copy_specific_attributes(mi, cpb) + newmi.template_to_attribute(mi, cpb) else: newmi = mi set_metadata(stream, newmi, stream_type=ext) diff --git a/src/calibre/gui2/library/models.py b/src/calibre/gui2/library/models.py index 9da5420681..a808fd9c43 100644 --- a/src/calibre/gui2/library/models.py +++ b/src/calibre/gui2/library/models.py @@ -750,8 +750,10 @@ class BooksModel(QAbstractTableModel): # {{{ self.refresh(reset=True) return True - self.db.set_custom(self.db.id(row), val, extra=s_index, + id = self.db.id(row) + self.db.set_custom(id, val, extra=s_index, label=label, num=None, append=False, notify=True) + self.refresh_ids([id], current_row=row) return True def setData(self, index, value, role): diff --git a/src/calibre/gui2/preferences/plugboard.py b/src/calibre/gui2/preferences/plugboard.py index 124654b643..011131ae48 100644 --- a/src/calibre/gui2/preferences/plugboard.py +++ b/src/calibre/gui2/preferences/plugboard.py @@ -56,18 +56,19 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): self.formats.insert(1, plugboard_any_format_value) self.new_format.addItems(self.formats) - self.fields = [''] - for f in self.db.all_field_keys(): - if self.db.field_metadata[f].get('rec_index', None) is not None and\ - self.db.field_metadata[f]['datatype'] is not None and \ - self.db.field_metadata[f]['search_terms']: - self.fields.append(f) - self.fields.sort(cmp=field_cmp) + self.source_fields = [''] + for f in self.db.custom_field_keys(): + if self.db.field_metadata[f]['datatype'] == 'composite': + self.source_fields.append(f) + self.source_fields.sort(cmp=field_cmp) + + self.dest_fields = ['', 'authors', 'author_sort', 'publisher', + 'tags', 'title'] self.source_widgets = [] self.dest_widgets = [] for i in range(0, 10): - w = QtGui.QComboBox(self) + w = QtGui.QLineEdit(self) self.source_widgets.append(w) self.fields_layout.addWidget(w, 5+i, 0, 1, 1) w = QtGui.QComboBox(self) @@ -101,14 +102,13 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): self.ok_button.setEnabled(True) self.del_button.setEnabled(True) for w in self.source_widgets: - w.addItems(self.fields) + w.clear() for w in self.dest_widgets: - w.addItems(self.fields) + w.addItems(self.dest_fields) def set_field(self, i, src, dst): - idx = self.fields.index(src) - self.source_widgets[i].setCurrentIndex(idx) - idx = self.fields.index(dst) + self.source_widgets[i].setText(src) + idx = self.dest_fields.index(dst) self.dest_widgets[i].setCurrentIndex(idx) def edit_device_changed(self, txt): @@ -216,11 +216,11 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): def ok_clicked(self): pb = {} for i in range(0, len(self.source_widgets)): - s = self.source_widgets[i].currentIndex() - if s != 0: + s = unicode(self.source_widgets[i].text()) + if s: d = self.dest_widgets[i].currentIndex() if d != 0: - pb[self.fields[s]] = self.fields[d] + pb[s] = self.dest_fields[d] if len(pb) == 0: if self.current_format in self.current_plugboards: fpb = self.current_plugboards[self.current_format] @@ -266,9 +266,7 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): if d not in self.current_plugboards[f]: continue ops = [] - for op in self.fields: - if op not in self.current_plugboards[f][d]: - continue + for op in self.current_plugboards[f][d]: ops.append(op + '->' + self.current_plugboards[f][d][op]) txt += '%s:%s [%s]\n'%(f, d, ', '.join(ops)) self.existing_plugboards.setPlainText(txt) diff --git a/src/calibre/gui2/preferences/plugboard.ui b/src/calibre/gui2/preferences/plugboard.ui index f88af8ff50..79a07be1f7 100644 --- a/src/calibre/gui2/preferences/plugboard.ui +++ b/src/calibre/gui2/preferences/plugboard.ui @@ -87,6 +87,9 @@ QPlainTextEdit::NoWrap + + true + @@ -109,7 +112,7 @@ - Source field + Source template Qt::AlignCenter diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index a36dbe57a9..42720c5e83 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -672,7 +672,7 @@ class ResultCache(SearchQueryParser): # {{{ if len(self.composites) > 0: mi = db.get_metadata(id, index_is_id=True) for k,c in self.composites: - self._data[id][c] = mi.format_field(k)[1] + self._data[id][c] = mi.get(k) self._map[0:0] = ids self._map_filtered[0:0] = ids @@ -702,7 +702,7 @@ class ResultCache(SearchQueryParser): # {{{ if len(self.composites) > 0: mi = db.get_metadata(item[0], index_is_id=True) for k,c in self.composites: - item[c] = mi.format_field(k)[1] + item[c] = mi.get(k) self._map = [i[0] for i in self._data if i is not None] if field is not None: diff --git a/src/calibre/library/save_to_disk.py b/src/calibre/library/save_to_disk.py index a2c8a62694..113ebf823a 100644 --- a/src/calibre/library/save_to_disk.py +++ b/src/calibre/library/save_to_disk.py @@ -112,8 +112,6 @@ class SafeFormat(TemplateFormatter): Provides a format function that substitutes '' for any missing value ''' - composite_values = {} - def get_value(self, key, args, kwargs): try: b = self.book.get_user_metadata(key, False) @@ -131,11 +129,6 @@ class SafeFormat(TemplateFormatter): except: return '' - def safe_format(self, fmt, kwargs, error_value, book, sanitize=None): - self.composite_values = {} - return TemplateFormatter.safe_format(self, fmt, kwargs, error_value, - book, sanitize) - safe_formatter = SafeFormat() def get_components(template, mi, id, timefmt='%b %Y', length=250, @@ -279,7 +272,7 @@ def save_book_to_disk(id, db, root, opts, length): try: if cpb: newmi = mi.deepcopy() - newmi.copy_specific_attributes(mi, cpb) + newmi.template_to_attribute(mi, cpb) else: newmi = mi set_metadata(stream, newmi, fmt) diff --git a/src/calibre/utils/formatter.py b/src/calibre/utils/formatter.py index f95a6deee5..502574dd3c 100644 --- a/src/calibre/utils/formatter.py +++ b/src/calibre/utils/formatter.py @@ -11,6 +11,10 @@ class TemplateFormatter(string.Formatter): Provides a format function that substitutes '' for any missing value ''' + # Dict to do recursion detection. It is up the the individual get_value + # method to use it. It is cleared when starting to format a template + composite_values = {} + def __init__(self): string.Formatter.__init__(self) self.book = None @@ -114,6 +118,7 @@ class TemplateFormatter(string.Formatter): self.kwargs = kwargs self.book = book self.sanitize = sanitize + self.composite_values = {} try: ans = self.vformat(fmt, [], kwargs).strip() except: From 1b41568d4c9aa67331041a09210faa1299345e29 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 29 Sep 2010 15:12:01 +0100 Subject: [PATCH 12/13] 1) Add validation to plugboard gui 2) allow plugboards to use metadata fields with no field metadata (e.g., language) --- src/calibre/ebooks/metadata/book/base.py | 2 +- src/calibre/gui2/preferences/plugboard.py | 21 ++++++- src/calibre/gui2/preferences/plugboard.ui | 76 ++++++++++++++++++----- 3 files changed, 81 insertions(+), 18 deletions(-) diff --git a/src/calibre/ebooks/metadata/book/base.py b/src/calibre/ebooks/metadata/book/base.py index 56df573cee..17aa2d5603 100644 --- a/src/calibre/ebooks/metadata/book/base.py +++ b/src/calibre/ebooks/metadata/book/base.py @@ -308,7 +308,7 @@ class Metadata(object): val = composite_formatter.safe_format\ (src, other, 'PLUGBOARD TEMPLATE ERROR', other) dfm = self.metadata_for_field(attrs[src]) - if dfm['is_multiple']: + if dfm and dfm['is_multiple']: self.set(attrs[src], [f.strip() for f in val.split(',') if f.strip()]) else: diff --git a/src/calibre/gui2/preferences/plugboard.py b/src/calibre/gui2/preferences/plugboard.py index 011131ae48..3742eb24d0 100644 --- a/src/calibre/gui2/preferences/plugboard.py +++ b/src/calibre/gui2/preferences/plugboard.py @@ -13,6 +13,7 @@ from calibre.gui2.preferences.plugboard_ui import Ui_Form from calibre.customize.ui import metadata_writers, device_plugins from calibre.library.save_to_disk import plugboard_any_format_value, \ plugboard_any_device_value, plugboard_save_to_disk_value +from calibre.utils.formatter import validation_formatter class ConfigWidget(ConfigWidgetBase, Ui_Form): @@ -62,12 +63,13 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): self.source_fields.append(f) self.source_fields.sort(cmp=field_cmp) - self.dest_fields = ['', 'authors', 'author_sort', 'publisher', - 'tags', 'title'] + self.dest_fields = ['', + 'authors', 'author_sort', 'language', 'publisher', + 'tags', 'title', 'title_sort'] self.source_widgets = [] self.dest_widgets = [] - for i in range(0, 10): + for i in range(0, len(self.dest_fields)-1): w = QtGui.QLineEdit(self) self.source_widgets.append(w) self.fields_layout.addWidget(w, 5+i, 0, 1, 1) @@ -220,7 +222,20 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): if s: d = self.dest_widgets[i].currentIndex() if d != 0: + try: + validation_formatter.validate(s) + except Exception, err: + error_dialog(self, _('Invalid template'), + '

'+_('The template %s is invalid:')%s + \ + '
'+str(err), show=True) + return pb[s] = self.dest_fields[d] + else: + error_dialog(self, _('Invalid destination'), + '

'+_('The destination field cannot be blank'), + show=True) + return + if len(pb) == 0: if self.current_format in self.current_plugboards: fpb = self.current_plugboards[self.current_format] diff --git a/src/calibre/gui2/preferences/plugboard.ui b/src/calibre/gui2/preferences/plugboard.ui index 79a07be1f7..4a3192aab5 100644 --- a/src/calibre/gui2/preferences/plugboard.ui +++ b/src/calibre/gui2/preferences/plugboard.ui @@ -17,7 +17,12 @@ - Here you can control what metadata calibre uses when saving or sending books: + Here you can change the metadata calibre uses when saving or sending books. One possibility is to alter the title to contain series informaton. Another would be to change the author sort. + +Use this dialog to define for a format (or all formats) and a device (or all devices) the template to be used to find the value to assign to a destination field. Often the templates will contain simple references to composite columns, but this is not necessary. You can put arbitrary templates in the source box. + + + Qt::PlainText true @@ -129,7 +134,7 @@ - + Qt::Vertical @@ -143,18 +148,61 @@ - - - Save - - - - - - - Delete - - + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Save plugboard + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Delete plugboard + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + From 084b0cff49bdfbf3664881e69e4fe3692e0bfc29 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 29 Sep 2010 15:21:02 +0100 Subject: [PATCH 13/13] Fix intro text a bit. --- src/calibre/gui2/preferences/plugboard.ui | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/calibre/gui2/preferences/plugboard.ui b/src/calibre/gui2/preferences/plugboard.ui index 4a3192aab5..efe500aebd 100644 --- a/src/calibre/gui2/preferences/plugboard.ui +++ b/src/calibre/gui2/preferences/plugboard.ui @@ -17,9 +17,13 @@ - Here you can change the metadata calibre uses when saving or sending books. One possibility is to alter the title to contain series informaton. Another would be to change the author sort. + Here you can change the metadata calibre uses to update a book when saving to disk or sending to device. -Use this dialog to define for a format (or all formats) and a device (or all devices) the template to be used to find the value to assign to a destination field. Often the templates will contain simple references to composite columns, but this is not necessary. You can put arbitrary templates in the source box. +Use this dialog to define a 'plugboard' for for a format (or all formats) and a device (or all devices). The plugboard spefies what template is connected to what field. The template is used to find compute a value, and that value is assigned to the connected field. + +Often templates will contain simple references to composite columns, but this is not necessary. You can use any template in a source box that you can use elsewhere in calibre. + +One possible use for a plugboard is to alter the title to contain series informaton. Another would be to change the author sort, something that mobi users might do to force it to use the ';' that the kindle requires. A third would be to specify the language. Qt::PlainText @@ -29,7 +33,14 @@ Use this dialog to define for a format (or all formats) and a device (or all dev - + + + + Qt::Horizontal + + + + @@ -112,7 +123,7 @@ Use this dialog to define for a format (or all formats) and a device (or all dev - +