diff --git a/src/calibre/gui2/dialogs/metadata_bulk.py b/src/calibre/gui2/dialogs/metadata_bulk.py index 2c88556d7b..36a035cd94 100644 --- a/src/calibre/gui2/dialogs/metadata_bulk.py +++ b/src/calibre/gui2/dialogs/metadata_bulk.py @@ -6,7 +6,8 @@ __copyright__ = '2008, Kovid Goyal ' import re, os from PyQt4.Qt import Qt, QDialog, QGridLayout, QVBoxLayout, QFont, QLabel, \ - pyqtSignal, QDialogButtonBox, QDate, QLineEdit + pyqtSignal, QDialogButtonBox, QInputDialog, QLineEdit, \ + QMessageBox, QDate from calibre.gui2.dialogs.metadata_bulk_ui import Ui_MetadataBulkDialog from calibre.gui2.dialogs.tag_editor import TagEditor @@ -16,7 +17,7 @@ from calibre.ebooks.metadata.meta import get_metadata from calibre.gui2.custom_column_widgets import populate_metadata_page from calibre.gui2 import error_dialog, ResizableDialog, UNDEFINED_QDATE from calibre.gui2.progress_indicator import ProgressIndicator -from calibre.utils.config import dynamic +from calibre.utils.config import dynamic, JSONConfig from calibre.utils.titlecase import titlecase from calibre.utils.icu import sort_key, capitalize from calibre.utils.config import prefs, tweaks @@ -451,6 +452,15 @@ class MetadataBulkDialog(ResizableDialog, Ui_MetadataBulkDialog): self.results_count.valueChanged[int].connect(self.s_r_display_bounds_changed) self.starting_from.valueChanged[int].connect(self.s_r_display_bounds_changed) + self.save_button.clicked.connect(self.s_r_save_query) + self.remove_button.clicked.connect(self.s_r_remove_query) + + self.queries = JSONConfig("search_replace_queries") + self.query_field.addItem("") + self.query_field.addItems(sorted([q for q in self.queries], key=sort_key)) + self.query_field.currentIndexChanged[str].connect(self.s_r_query_change) + self.query_field.setCurrentIndex(0) + def s_r_get_field(self, mi, field): if field: if field == '{template}': @@ -862,3 +872,117 @@ class MetadataBulkDialog(ResizableDialog, Ui_MetadataBulkDialog): def series_changed(self, *args): self.write_series = True + def s_r_remove_query(self, *args): + if self.query_field.currentIndex() == 0: + return + + ret = QMessageBox.question(self, _("Delete saved search/replace"), + _("The selected saved search/replace will be deleted. " + "Are you sure?"), + QMessageBox.Ok, QMessageBox.Cancel) + + if ret == QMessageBox.Cancel: + return + + item_id = self.query_field.currentIndex() + item_name = unicode(self.query_field.currentText()) + + self.query_field.blockSignals(True) + self.query_field.removeItem(item_id) + self.query_field.blockSignals(False) + self.query_field.setCurrentIndex(0) + + if item_name in self.queries.keys(): + del(self.queries[item_name]) + self.queries.commit() + + def s_r_save_query(self, *args): + name, ok = QInputDialog.getText(self, _('Save search/replace'), + _('Search/replace name:')) + if not ok: + return + + new = True + name = unicode(name) + if name in self.queries.keys(): + ret = QMessageBox.question(self, _("Save search/replace"), + _("That saved search/replace already exists and will be overwritten. " + "Are you sure?"), + QMessageBox.Ok, QMessageBox.Cancel) + if ret == QMessageBox.Cancel: + return + new = False + + query = {} + query['name'] = name + query['search_field'] = unicode(self.search_field.currentText()) + query['search_mode'] = unicode(self.search_mode.currentText()) + query['s_r_template'] = unicode(self.s_r_template.text()) + query['search_for'] = unicode(self.search_for.text()) + query['case_sensitive'] = self.case_sensitive.isChecked() + query['replace_with'] = unicode(self.replace_with.text()) + query['replace_func'] = unicode(self.replace_func.currentText()) + query['destination_field'] = unicode(self.destination_field.currentText()) + query['replace_mode'] = unicode(self.replace_mode.currentText()) + query['comma_separated'] = self.comma_separated.isChecked() + query['results_count'] = self.results_count.value() + query['starting_from'] = self.starting_from.value() + query['multiple_separator'] = unicode(self.multiple_separator.text()) + + self.queries[name] = query + self.queries.commit() + + if new: + self.query_field.blockSignals(True) + self.query_field.clear() + self.query_field.addItem('') + self.query_field.addItems(sorted([q for q in self.queries], key=sort_key)) + self.query_field.blockSignals(False) + self.query_field.setCurrentIndex(self.query_field.findText(name)) + + def s_r_query_change(self, item_name): + if not item_name: + self.s_r_reset_query_fields() + return + item = self.queries.get(unicode(item_name), None) + if item is None: + self.s_r_reset_query_fields() + return + + def set_index(attr, txt): + try: + attr.setCurrentIndex(attr.findText(txt)) + except: + attr.setCurrentIndex(0) + + set_index(self.search_mode, item['search_mode']) + set_index(self.search_field, item['search_field']) + self.s_r_template.setText(item['s_r_template']) + self.s_r_template_changed() #simulate gain/loss of focus + self.search_for.setText(item['search_for']) + self.case_sensitive.setChecked(item['case_sensitive']) + self.replace_with.setText(item['replace_with']) + set_index(self.replace_func, item['replace_func']) + set_index(self.destination_field, item['destination_field']) + set_index(self.replace_mode, item['replace_mode']) + self.comma_separated.setChecked(item['comma_separated']) + self.results_count.setValue(int(item['results_count'])) + self.starting_from.setValue(int(item['starting_from'])) + self.multiple_separator.setText(item['multiple_separator']) + + def s_r_reset_query_fields(self): + # Don't reset the search mode. The user will probably want to use it + # as it was + self.search_field.setCurrentIndex(0) + self.s_r_template.setText("") + self.search_for.setText("") + self.case_sensitive.setChecked(False) + self.replace_with.setText("") + self.replace_func.setCurrentIndex(0) + self.destination_field.setCurrentIndex(0) + self.replace_mode.setCurrentIndex(0) + self.comma_separated.setChecked(True) + self.results_count.setValue(999) + self.starting_from.setValue(1) + self.multiple_separator.setText(" ::: ") + diff --git a/src/calibre/gui2/dialogs/metadata_bulk.ui b/src/calibre/gui2/dialogs/metadata_bulk.ui index f8ae926be6..481a485bc2 100644 --- a/src/calibre/gui2/dialogs/metadata_bulk.ui +++ b/src/calibre/gui2/dialogs/metadata_bulk.ui @@ -7,7 +7,7 @@ 0 0 850 - 650 + 700 @@ -45,7 +45,7 @@ 0 0 842 - 589 + 639 @@ -574,7 +574,7 @@ Future conversion of these books will use the default settings. QLayout::SetMinimumSize - + true @@ -584,14 +584,91 @@ Future conversion of these books will use the default settings. - + + + + + Qt::Horizontal + + + + + + Load searc&h/replace: + + + search_field + + + + + + + Select saved search/replace to load. + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 20 + 20 + + + + + + + + Save current search/replace + + + Sa&ve + + + + + + + Delete saved search/replace + + + Delete + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + Search &field: @@ -601,14 +678,14 @@ Future conversion of these books will use the default settings. - + The name of the field that you want to search - + @@ -642,7 +719,7 @@ Future conversion of these books will use the default settings. - + Te&mplate: @@ -652,7 +729,7 @@ Future conversion of these books will use the default settings. - + @@ -665,7 +742,7 @@ Future conversion of these books will use the default settings. - + &Search for: @@ -675,7 +752,7 @@ Future conversion of these books will use the default settings. - + @@ -688,7 +765,7 @@ Future conversion of these books will use the default settings. - + Check this box if the search string must match exactly upper and lower case. Uncheck it if case is to be ignored @@ -701,7 +778,7 @@ Future conversion of these books will use the default settings. - + &Replace with: @@ -711,14 +788,14 @@ Future conversion of these books will use the default settings. - + The replacement text. The matched search text will be replaced with this string - + @@ -753,7 +830,7 @@ field is processed. In regular expression mode, only the matched text is process - + &Destination field: @@ -763,7 +840,7 @@ field is processed. In regular expression mode, only the matched text is process - + The field that the text will be put into after all replacements. @@ -771,7 +848,7 @@ If blank, the source field is used if the field is modifiable - + @@ -820,7 +897,7 @@ not multiple and the destination field is multiple - + @@ -906,7 +983,7 @@ not multiple and the destination field is multiple - + QFrame::NoFrame @@ -919,8 +996,8 @@ not multiple and the destination field is multiple 0 0 - 197 - 60 + 826 + 323 @@ -1030,6 +1107,9 @@ not multiple and the destination field is multiple series_numbering_restarts series_start_number button_box + query_field + save_button + remove_button search_field search_mode s_r_template @@ -1045,6 +1125,23 @@ not multiple and the destination field is multiple multiple_separator test_text test_result + scrollArea + central_widget + swap_title_and_author + clear_series + adddate + clear_adddate_button + apply_adddate + pubdate + clear_pubdate_button + apply_pubdate + remove_format + change_title_to_title_case + remove_conversion_settings + cover_generate + cover_remove + cover_from_fmt + scrollArea11 diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index d68be3b7d6..8b574948ff 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -186,7 +186,7 @@ class TagsView(QTreeView): # {{{ self.clear() def context_menu_handler(self, action=None, category=None, - key=None, index=None): + key=None, index=None, negate=None): if not action: return try: @@ -199,6 +199,9 @@ class TagsView(QTreeView): # {{{ if action == 'manage_categories': self.user_category_edit.emit(category) return + if action == 'search_category': + self.tags_marked.emit(category + ':' + str(not negate)) + return if action == 'manage_searches': self.saved_search_edit.emit(category) return @@ -268,6 +271,15 @@ class TagsView(QTreeView): # {{{ m.addAction(col, partial(self.context_menu_handler, action='show', category=col)) + # search by category + self.context_menu.addAction( + _('Search for books in category %s')%category, + partial(self.context_menu_handler, action='search_category', + category=key, negate=False)) + self.context_menu.addAction( + _('Search for books not in category %s')%category, + partial(self.context_menu_handler, action='search_category', + category=key, negate=True)) # Offer specific editors for tags/series/publishers/saved searches self.context_menu.addSeparator() if key in ['tags', 'publisher', 'series'] or \ diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index 291d71f572..77e75736cf 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -135,7 +135,7 @@ def _match(query, value, matchkind): pass return False -class CacheRow(list): +class CacheRow(list): # {{{ def __init__(self, db, composites, val): self.db = db @@ -166,14 +166,16 @@ class CacheRow(list): def __getslice__(self, i, j): return self.__getitem__(slice(i, j)) +# }}} class ResultCache(SearchQueryParser): # {{{ ''' Stores sorted and filtered metadata in memory. ''' - def __init__(self, FIELD_MAP, field_metadata): + def __init__(self, FIELD_MAP, field_metadata, db_prefs=None): self.FIELD_MAP = FIELD_MAP + self.db_prefs = db_prefs self.composites = {} for key in field_metadata: if field_metadata[key]['datatype'] == 'composite': @@ -191,7 +193,7 @@ class ResultCache(SearchQueryParser): # {{{ def break_cycles(self): self._data = self.field_metadata = self.FIELD_MAP = \ self.numeric_search_relops = self.date_search_relops = \ - self.all_search_locations = None + self.all_search_locations = self.db_prefs = None def __getitem__(self, row): @@ -405,6 +407,22 @@ class ResultCache(SearchQueryParser): # {{{ matches.add(item[0]) return matches + def get_user_category_matches(self, location, query, candidates): + res = set([]) + if self.db_prefs is None: + return res + user_cats = self.db_prefs.get('user_categories', []) + if location not in user_cats: + return res + c = set(candidates) + for (item, category, ign) in user_cats[location]: + s = self.get_matches(category, '=' + item, candidates=c) + c -= s + res |= s + if query == 'false': + return candidates - res + return res + def get_matches(self, location, query, allow_recursion=True, candidates=None): matches = set([]) if candidates is None: @@ -443,6 +461,10 @@ class ResultCache(SearchQueryParser): # {{{ return self.get_numeric_matches(location, query[1:], candidates, val_func=vf) + # check for user categories + if len(location) >= 2 and location.startswith('@'): + return self.get_user_category_matches(location[1:], query.lower(), + candidates) # everything else, or 'all' matches matchkind = CONTAINS_MATCH if (len(query) > 1): @@ -468,6 +490,8 @@ class ResultCache(SearchQueryParser): # {{{ for x in range(len(self.FIELD_MAP)): col_datatype.append('') for x in self.field_metadata: + if x.startswith('@'): + continue if len(self.field_metadata[x]['search_terms']): db_col[x] = self.field_metadata[x]['rec_index'] if self.field_metadata[x]['datatype'] not in \ diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 5638bad1ee..f2b2c94e31 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -332,7 +332,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): traceback.print_exc() self.book_on_device_func = None - self.data = ResultCache(self.FIELD_MAP, self.field_metadata) + self.data = ResultCache(self.FIELD_MAP, self.field_metadata, db_prefs=self.prefs) self.search = self.data.search self.search_getting_ids = self.data.search_getting_ids self.refresh = functools.partial(self.data.refresh, self) diff --git a/src/calibre/library/field_metadata.py b/src/calibre/library/field_metadata.py index e9402d1227..78fe899fa8 100644 --- a/src/calibre/library/field_metadata.py +++ b/src/calibre/library/field_metadata.py @@ -475,6 +475,8 @@ class FieldMetadata(dict): val = self._tb_cats[key] if val['is_category'] and val['kind'] in ('user', 'search'): del self._tb_cats[key] + if key in self._search_term_map: + del self._search_term_map[key] def cc_series_index_column_for(self, key): return self._tb_cats[key]['rec_index'] + 1 @@ -482,11 +484,12 @@ class FieldMetadata(dict): def add_user_category(self, label, name): if label in self._tb_cats: raise ValueError('Duplicate user field [%s]'%(label)) - self._tb_cats[label] = {'table':None, 'column':None, - 'datatype':None, 'is_multiple':None, - 'kind':'user', 'name':name, - 'search_terms':[], 'is_custom':False, + self._tb_cats[label] = {'table':None, 'column':None, + 'datatype':None, 'is_multiple':None, + 'kind':'user', 'name':name, + 'search_terms':[label],'is_custom':False, 'is_category':True} + self._add_search_terms_to_map(label, [label]) def add_search_category(self, label, name): if label in self._tb_cats: