diff --git a/resources/images/plugboard.png b/resources/images/plugboard.png new file mode 100644 index 0000000000..88f0869b8d Binary files /dev/null and b/resources/images/plugboard.png differ diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py index 50d8e29373..d4d4ee5d4e 100644 --- a/src/calibre/customize/builtins.py +++ b/src/calibre/customize/builtins.py @@ -797,6 +797,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 plugboards') + 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') @@ -857,8 +868,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..17aa2d5603 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 @@ -182,7 +183,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 +295,28 @@ class Metadata(object): _data = object.__getattribute__(self, '_data') _data['user_metadata'][field] = metadata + def template_to_attribute(self, other, attrs): + ''' + 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: + val = composite_formatter.safe_format\ + (src, other, 'PLUGBOARD TEMPLATE ERROR', other) + dfm = self.metadata_for_field(attrs[src]) + if dfm and dfm['is_multiple']: + self.set(attrs[src], + [f.strip() for f in val.split(',') if f.strip()]) + else: + self.set(attrs[src], val) + 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/actions/choose_library.py b/src/calibre/gui2/actions/choose_library.py index 79406da40c..2f8beab976 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,16 @@ class ChooseLibraryAction(InterfaceAction): self.stats.remove(location) self.build_menus() + def backup_status(self, location): + dirty_text = 'no' + try: + 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/device.py b/src/calibre/gui2/device.py index 58c5e5d9ad..3da4fddb5d 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): # {{{ @@ -317,19 +319,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.__class__.__name__ + cpb = None + if ext in plugboards: + cpb = plugboards[ext] + 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 plugboard_any_device_value in plugboards[ext]: + cpb = cpb[plugboard_any_device_value] + else: + cpb = None + + if DEBUG: + prints('Using plugboard', ext, dev_name, 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.template_to_attribute(mi, cpb) + else: + newmi = mi + set_metadata(stream, newmi, stream_type=ext) except: if DEBUG: prints(traceback.format_exc(), file=sys.__stdout__) @@ -338,12 +361,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 +1280,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', {}) 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/dialogs/metadata_bulk.py b/src/calibre/gui2/dialogs/metadata_bulk.py index b0ce0a1e6d..347ed36d7c 100644 --- a/src/calibre/gui2/dialogs/metadata_bulk.py +++ b/src/calibre/gui2/dialogs/metadata_bulk.py @@ -3,42 +3,110 @@ __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] + percent = int((self.current_index*100)/float(len(self.ids))) + self.msg.setText(self.msg_text.format(self.phases[self.current_phase], + percent)) + 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 +126,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 +160,20 @@ 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) + self.current_index = len(self.ids) + # 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 +502,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 +511,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 +572,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 +660,25 @@ 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 = 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() 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/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 new file mode 100644 index 0000000000..3742eb24d0 --- /dev/null +++ b/src/calibre/gui2/preferences/plugboard.py @@ -0,0 +1,307 @@ +#!/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 +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): + + def genesis(self, gui): + self.gui = gui + self.db = gui.library_view.model().db + self.current_plugboards = self.db.prefs.get('plugboards',{}) + self.current_device = None + self.current_format = None + + 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.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', 'language', 'publisher', + 'tags', 'title', 'title_sort'] + + self.source_widgets = [] + self.dest_widgets = [] + 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) + 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: + 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) + self.del_button.setEnabled(True) + for w in self.source_widgets: + w.clear() + for w in self.dest_widgets: + w.addItems(self.dest_fields) + + def set_field(self, i, src, 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): + if txt == '': + self.current_device = None + return + 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 'edit_device_changed: none format!' + return + dpb = fpb.get(self.current_device, None) + if dpb is None: + print 'edit_device_changed: none device!' + return + self.set_fields() + 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 == '': + self.edit_device.setCurrentIndex(0) + self.current_format = None + self.current_device = None + return + self.clear_fields(new_boxes=True) + txt = unicode(txt) + fpb = self.current_plugboards.get(txt, None) + if fpb is None: + print 'edit_format_changed: 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 + self.clear_fields(edit_boxes=True) + self.current_device = unicode(txt) + error = False + if self.current_format == plugboard_any_format_value: + # user specified any format. + for f in self.current_plugboards: + 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 devs: + # specific format/current device in list. conflict + error = True + break + if self.current_device == plugboard_any_device_value: + # any device and a specific device already there. conflict + error = True + break + else: + # 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 + 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, '', + _('That format and device already has a plugboard or ' + 'conflicts with another 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 + self.clear_fields(edit_boxes=True) + self.current_format = unicode(txt) + self.new_device.setCurrentIndex(0) + + def ok_clicked(self): + pb = {} + for i in range(0, len(self.source_widgets)): + s = unicode(self.source_widgets[i].text()) + 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] + 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 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 + 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) + 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.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) + + 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..6329a78ce1 --- /dev/null +++ b/src/calibre/gui2/preferences/plugboard.ui @@ -0,0 +1,224 @@ + + + Form + + + + 0 + 0 + 707 + 340 + + + + Form + + + + + + 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 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 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 + + + true + + + + + + + Qt::Horizontal + + + + + + + + + Format (choose first) + + + Qt::AlignCenter + + + + + + + Device (choose second) + + + Qt::AlignCenter + + + + + + + Add new plugboard + + + + + + + + + + + + + Edit existing plugboard + + + + + + + + + + + + + Existing plugboards + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + QPlainTextEdit::NoWrap + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + Source template + + + Qt::AlignCenter + + + + + + + Destination field + + + Qt::AlignCenter + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Save plugboard + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Delete plugboard + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index 5a30d0f7db..0c3904532e 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -138,25 +138,46 @@ 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: + # Happens during shutdown + break 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 + if not self.keep_running: 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: + try: + traceback.print_exc() + except: + # happens during shutdown + break + continue + try: + with self.lock: + self.cache[id_] = img + except: + # Happens during interpreter shutdown + break def set_cache(self, ids): with self.lock: @@ -660,7 +681,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 @@ -690,7 +711,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: @@ -779,7 +800,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() 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..08dd74af29 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 @@ -627,6 +635,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 +1297,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 +1455,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 +1484,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 +1503,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 +1524,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 +1548,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 +1572,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 +1583,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 +1668,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. diff --git a/src/calibre/library/save_to_disk.py b/src/calibre/library/save_to_disk.py index 088b6352af..afeb5ee0b9 100644 --- a/src/calibre/library/save_to_disk.py +++ b/src/calibre/library/save_to_disk.py @@ -19,6 +19,11 @@ from calibre.ebooks.metadata import fmt_sidx from calibre.ebooks.metadata import title_sort from calibre import strftime +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}' @@ -107,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) @@ -126,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, @@ -245,6 +243,23 @@ def save_book_to_disk(id, db, root, opts, length): written = False for fmt in formats: + 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] + 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] + if dev_name in cpb: + cpb = cpb[dev_name] + else: + cpb = None + #prints('Using plugboard:', fmt, cpb) data = db.format(id, fmt, index_is_id=True) if data is None: continue @@ -255,7 +270,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.template_to_attribute(mi, cpb) + else: + newmi = mi + set_metadata(stream, newmi, fmt) except: traceback.print_exc() stream.seek(0) 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: