diff --git a/src/calibre/db/cache.py b/src/calibre/db/cache.py index d665534715..6058865fd4 100644 --- a/src/calibre/db/cache.py +++ b/src/calibre/db/cache.py @@ -9,7 +9,7 @@ __docformat__ = 'restructuredtext en' import os, traceback, random, shutil, operator from io import BytesIO -from collections import defaultdict +from collections import defaultdict, Set, MutableSet from functools import wraps, partial from future_builtins import zip @@ -1564,24 +1564,82 @@ class Cache(object): return val_map @write_api - def rename_items(self, field, item_id_to_new_name_map, change_index=True): + def rename_items(self, field, item_id_to_new_name_map, change_index=True, restrict_to_book_ids=None): ''' Rename items from a many-one or many-many field such as tags or series. :param change_index: When renaming in a series-like field also change the series_index values. + :param restrict_to_book_ids: An optional set of book ids for which the rename is to be performed, defaults to all books. ''' + f = self.fields[field] - try: - func = f.table.rename_item - except AttributeError: - raise ValueError('Cannot rename items for one-one fields: %s' % field) affected_books = set() - moved_books = set() - id_map = {} try: sv = f.metadata['is_multiple']['ui_to_list'] except (TypeError, KeyError, AttributeError): sv = None + + if restrict_to_book_ids is not None: + # We have a VL. Only change the item name for those books + if not isinstance(restrict_to_book_ids, (Set, MutableSet)): + restrict_to_book_ids = frozenset(restrict_to_book_ids) + id_map = {} + default_process_map = {} + for old_id, new_name in item_id_to_new_name_map.iteritems(): + new_names = tuple(x.strip() for x in new_name.split(sv)) if sv else (new_name,) + # Get a list of books in the VL with the item + books_with_id = f.books_for(old_id) + books_to_process = books_with_id & restrict_to_book_ids + if len(books_with_id) == len(books_to_process): + # All the books with the ID are in the VL, so we can use + # the normal processing + default_process_map[old_id] = new_name + elif books_to_process: + affected_books.update(books_to_process) + newvals = {} + for book_id in books_to_process: + # Get the current values, remove the one being renamed, then add + # the new value(s) back. + vals = self._field_for(field, book_id) + # Check for is_multiple + if isinstance(vals, tuple): + # We must preserve order. + vals = list(vals) + # Don't need to worry about case here because we + # are fetching its one-true spelling. But lets be + # careful anyway + try: + dex = vals.index(self._get_item_name(field, old_id)) + # This can put the name back with a different case + vals[dex] = new_names[0] + # now add any other items if they aren't already there + if len(new_names) > 1: + set_vals = {icu_lower(x) for x in vals} + for v in new_names[1:]: + lv = icu_lower(v) + if lv not in set_vals: + vals.append(v) + set_vals.add(lv) + newvals[book_id] = vals + except Exception: + traceback.print_exc() + else: + newvals[book_id] = new_names[0] + # Allow case changes + self._set_field(field, newvals) + id_map[old_id] = self._get_item_id(field, new_names[0]) + if default_process_map: + ab, idm = self._rename_items(field, default_process_map, change_index=change_index) + affected_books.update(ab) + id_map.update(idm) + return affected_books, id_map + + try: + func = f.table.rename_item + except AttributeError: + raise ValueError('Cannot rename items for one-one fields: %s' % field) + moved_books = set() + id_map = {} for item_id, new_name in item_id_to_new_name_map.iteritems(): new_names = tuple(x.strip() for x in new_name.split(sv)) if sv else (new_name,) books, new_id = func(item_id, new_names[0], self.backend) @@ -1606,10 +1664,16 @@ class Cache(object): return affected_books, id_map @write_api - def remove_items(self, field, item_ids): - ''' Delete all items in the specified field with the specified ids. Returns the set of affected book ids. ''' + def remove_items(self, field, item_ids, restrict_to_book_ids=None): + ''' Delete all items in the specified field with the specified ids. + Returns the set of affected book ids. ``restrict_to_book_ids`` is an + optional set of books ids. If specified the items will only be removed + from those books. ''' field = self.fields[field] - affected_books = field.table.remove_items(item_ids, self.backend) + if restrict_to_book_ids is not None and not isinstance(restrict_to_book_ids, (MutableSet, Set)): + restrict_to_book_ids = frozenset(restrict_to_book_ids) + affected_books = field.table.remove_items(item_ids, self.backend, + restrict_to_book_ids=restrict_to_book_ids) if affected_books: if hasattr(field, 'index_field'): self._set_field(field.index_field.name, {bid:1.0 for bid in affected_books}) diff --git a/src/calibre/db/tables.py b/src/calibre/db/tables.py index 750714ef94..f94785dcab 100644 --- a/src/calibre/db/tables.py +++ b/src/calibre/db/tables.py @@ -265,8 +265,37 @@ class ManyToOneTable(Table): [(x,) for x in clean]) return clean - def remove_items(self, item_ids, db): + def remove_items(self, item_ids, db, restrict_to_book_ids=None): affected_books = set() + + if restrict_to_book_ids is not None: + items_to_process_normally = set() + # Check if all the books with the item are in the restriction. If + # so, process them normally + for item_id in item_ids: + books_to_process = self.col_book_map.get(item_id, set()) + books_not_to_delete = books_to_process - restrict_to_book_ids + if books_not_to_delete: + # Some books not in restriction. Must do special processing + books_to_delete = books_to_process & restrict_to_book_ids + # remove the books from the old id maps + self.col_book_map[item_id] = books_not_to_delete + for book_id in books_to_delete: + self.book_col_map.pop(book_id, None) + if books_to_delete: + # Delete links to the affected books from the link table. As + # this is a many-to-one mapping we know that we can delete + # links without checking the item ID + db.executemany( + 'DELETE FROM {0} WHERE book=?'.format(self.link_table), tuple((x,) for x in books_to_delete)) + affected_books |= books_to_delete + else: + # Process normally any items where the VL was not significant + items_to_process_normally.add(item_id) + if items_to_process_normally: + affected_books |= self.remove_items(items_to_process_normally, db) + return affected_books + for item_id in item_ids: val = self.id_map.pop(item_id, null) if val is null: @@ -373,8 +402,37 @@ class ManyToManyTable(ManyToOneTable): [(x,) for x in clean]) return clean - def remove_items(self, item_ids, db): + def remove_items(self, item_ids, db, restrict_to_book_ids=None): affected_books = set() + if restrict_to_book_ids is not None: + items_to_process_normally = set() + # Check if all the books with the item are in the restriction. If + # so, process them normally + for item_id in item_ids: + books_to_process = self.col_book_map.get(item_id, set()) + books_not_to_delete = books_to_process - restrict_to_book_ids + if books_not_to_delete: + # Some books not in restriction. Must do special processing + books_to_delete = books_to_process & restrict_to_book_ids + # remove the books from the old id maps + self.col_book_map[item_id] = books_not_to_delete + for book_id in books_to_delete: + self.book_col_map[book_id] = tuple( + x for x in self.book_col_map.get(book_id, ()) if x != item_id) + affected_books |= books_to_delete + else: + items_to_process_normally.add(item_id) + # Delete book/item pairs from the link table. We don't need to do + # anything with the main table because books with the old ID are + # still in the library. + db.executemany('DELETE FROM {0} WHERE {1}=? and {2}=?'.format( + self.link_table, 'book', self.metadata['link_column']), + [(b, i) for b in affected_books for i in item_ids]) + # Take care of any items where the VL was not significant + if items_to_process_normally: + affected_books |= self.remove_items(items_to_process_normally, db) + return affected_books + for item_id in item_ids: val = self.id_map.pop(item_id, null) if val is null: diff --git a/src/calibre/db/tests/writing.py b/src/calibre/db/tests/writing.py index eefc682372..1ce6817f1d 100644 --- a/src/calibre/db/tests/writing.py +++ b/src/calibre/db/tests/writing.py @@ -490,6 +490,27 @@ class WritingTest(BaseTest): self.assertEqual(c.all_field_names('#series'), {'My Series One'}) for bid in c.all_book_ids(): self.assertIn(c.field_for('#series', bid), (None, 'My Series One')) + + # Now test with restriction + cache = self.init_cache() + cache.set_field('tags', {1:'a,b,c', 2:'b,a', 3:'x,y,z'}) + cache.set_field('series', {1:'a', 2:'a', 3:'b'}) + cache.set_field('series_index', {1:8, 2:9, 3:3}) + tmap, smap = cache.get_id_map('tags'), cache.get_id_map('series') + self.assertEqual(cache.remove_items('tags', tmap, restrict_to_book_ids=()), set()) + self.assertEqual(cache.remove_items('tags', tmap, restrict_to_book_ids={1}), {1}) + self.assertEqual(cache.remove_items('series', smap, restrict_to_book_ids=()), set()) + self.assertEqual(cache.remove_items('series', smap, restrict_to_book_ids=(1,)), {1}) + c2 = self.init_cache() + for c in (cache, c2): + self.assertEqual(c.field_for('tags', 1), ()) + self.assertEqual(c.field_for('tags', 2), ('b', 'a')) + self.assertNotIn('c', set(c.get_id_map('tags').itervalues())) + self.assertEqual(c.field_for('series', 1), None) + self.assertEqual(c.field_for('series', 2), 'a') + self.assertEqual(c.field_for('series_index', 1), 1.0) + self.assertEqual(c.field_for('series_index', 2), 9) + # }}} def test_rename_items(self): # {{{ @@ -573,6 +594,19 @@ class WritingTest(BaseTest): for t in 'Something,Else,Entirely'.split(','): self.assertIn(t, f) self.assertNotIn('Tag One', f) + + # Test with restriction + cache = self.init_cache() + cache.set_field('tags', {1:'a,b,c', 2:'x,y,z', 3:'a,x,z'}) + tmap = {v:k for k, v in cache.get_id_map('tags').iteritems()} + self.assertEqual(cache.rename_items('tags', {tmap['a']:'r'}, restrict_to_book_ids=()), (set(), {})) + self.assertEqual(cache.rename_items('tags', {tmap['a']:'r', tmap['b']:'q'}, restrict_to_book_ids=(1,))[0], {1}) + self.assertEqual(cache.rename_items('tags', {tmap['x']:'X'}, restrict_to_book_ids=(2,))[0], {2}) + c2 = self.init_cache() + for c in (cache, c2): + self.assertEqual(c.field_for('tags', 1), ('r', 'q', 'c')) + self.assertEqual(c.field_for('tags', 2), ('X', 'y', 'z')) + self.assertEqual(c.field_for('tags', 3), ('a', 'X', 'z')) # }}} def test_composite_cache(self): # {{{ diff --git a/src/calibre/gui2/tag_browser/model.py b/src/calibre/gui2/tag_browser/model.py index 57f9aca620..b7afa288cd 100644 --- a/src/calibre/gui2/tag_browser/model.py +++ b/src/calibre/gui2/tag_browser/model.py @@ -855,6 +855,14 @@ class TagsModel(QAbstractItemModel): # {{{ self.drag_drop_finished.emit(ids) # }}} + def get_in_vl(self): + return self.db.data.get_base_restriction() or self.db.data.get_search_restriction() + + def get_book_ids_to_use(self): + if self.db.data.get_base_restriction() or self.db.data.get_search_restriction(): + return self.db.search('', return_matches=True, sort_results=False) + return None + def _get_category_nodes(self, sort): ''' Called by __init__. Do not directly call this method. @@ -863,21 +871,17 @@ class TagsModel(QAbstractItemModel): # {{{ self.categories = {} # Get the categories - if self.db.data.get_base_restriction() or self.db.data.get_search_restriction(): - try: - data = self.db.new_api.get_categories(sort=sort, - icon_map=self.category_icon_map, - book_ids=self.db.search('', return_matches=True, sort_results=False), - first_letter_sort = self.collapse_model == 'first letter') - except: - import traceback - traceback.print_exc() - data = self.db.new_api.get_categories(sort=sort, icon_map=self.category_icon_map, - first_letter_sort = self.collapse_model == 'first letter') - self.restriction_error.emit() - else: + try: + data = self.db.new_api.get_categories(sort=sort, + icon_map=self.category_icon_map, + book_ids=self.get_book_ids_to_use(), + first_letter_sort = self.collapse_model == 'first letter') + except: + import traceback + traceback.print_exc() data = self.db.new_api.get_categories(sort=sort, icon_map=self.category_icon_map, - first_letter_sort = self.collapse_model == 'first letter') + first_letter_sort = self.collapse_model == 'first letter') + self.restriction_error.emit() # Reconstruct the user categories, putting them into metadata self.db.field_metadata.remove_dynamic_categories() @@ -1042,21 +1046,14 @@ class TagsModel(QAbstractItemModel): # {{{ item.tag.name = val self.search_item_renamed.emit() # Does a refresh else: - if key == 'series': - self.db.rename_series(item.tag.id, val) - elif key == 'publisher': - self.db.rename_publisher(item.tag.id, val) - elif key == 'tags': - self.db.rename_tag(item.tag.id, val) - elif key == 'authors': - self.db.rename_author(item.tag.id, val) - elif self.db.field_metadata[key]['is_custom']: - self.db.rename_custom_item(item.tag.id, val, - label=self.db.field_metadata[key]['label']) + restrict_to_book_ids=self.get_book_ids_to_use() if item.use_vl else None + self.db.new_api.rename_items(key, {item.tag.id: val}, + restrict_to_book_ids=restrict_to_book_ids) self.tag_item_renamed.emit() item.tag.name = val item.tag.state = TAG_SEARCH_STATES['clear'] - self.rename_item_in_all_user_categories(name, key, val) + if not restrict_to_book_ids: + self.rename_item_in_all_user_categories(name, key, val) self.refresh_required.emit() return True diff --git a/src/calibre/gui2/tag_browser/ui.py b/src/calibre/gui2/tag_browser/ui.py index c46809db52..8e5c60128a 100644 --- a/src/calibre/gui2/tag_browser/ui.py +++ b/src/calibre/gui2/tag_browser/ui.py @@ -201,8 +201,12 @@ class TagBrowserMixin(object): # {{{ dialog will position the editor on that item. ''' - tags_model = self.tags_view.model() - result = tags_model.get_category_editor_data(category) + db = self.current_db + data = db.new_api.get_categories() + if category in data: + result = [(t.id, t.original_name, t.count) for t in data[category] if t.count > 0] + else: + result = None if result is None: return @@ -211,7 +215,6 @@ class TagBrowserMixin(object): # {{{ else: key = sort_key - db=self.library_view.model().db d = TagListEditor(self, cat_name=db.field_metadata[category]['name'], tag_to_match=tag, data=result, sorter=key) d.exec_() @@ -236,31 +239,23 @@ class TagBrowserMixin(object): # {{{ self.do_tag_item_renamed() self.tags_view.recount() - def do_tag_item_delete(self, category, item_id, orig_name): + def do_tag_item_delete(self, category, item_id, orig_name, restrict_to_book_ids=None): ''' Delete an item from some category. ''' + if restrict_to_book_ids: + msg = _('%s will be deleted from books in the virtual library. Are you sure?')%orig_name + else: + msg = _('%s will be deleted from all books. Are you sure?')%orig_name if not question_dialog(self.tags_view, title=_('Delete item'), - msg='

'+ - _('%s will be deleted from all books. Are you sure?') %orig_name, + msg='

'+ msg, skip_dialog_name='tag_item_delete', skip_dialog_msg=_('Show this confirmation again')): return - db = self.current_db - - if category == 'tags': - delete_func = db.delete_tag_using_id - elif category == 'series': - delete_func = db.delete_series_using_id - elif category == 'publisher': - delete_func = db.delete_publisher_using_id - else: # must be custom - cc_label = db.field_metadata[category]['label'] - delete_func = partial(db.delete_custom_item_using_id, label=cc_label) - m = self.tags_view.model() - if delete_func: - delete_func(item_id) + self.current_db.new_api.remove_items(category, (item_id,), restrict_to_book_ids=restrict_to_book_ids) + if restrict_to_book_ids is None: + m = self.tags_view.model() m.delete_item_from_all_user_categories(orig_name, category) # Clean up the library view diff --git a/src/calibre/gui2/tag_browser/view.py b/src/calibre/gui2/tag_browser/view.py index 21186b6226..48f0e78ebe 100644 --- a/src/calibre/gui2/tag_browser/view.py +++ b/src/calibre/gui2/tag_browser/view.py @@ -84,7 +84,7 @@ class TagsView(QTreeView): # {{{ search_item_renamed = pyqtSignal() drag_drop_finished = pyqtSignal(object) restriction_error = pyqtSignal() - tag_item_delete = pyqtSignal(object, object, object) + tag_item_delete = pyqtSignal(object, object, object, object) def __init__(self, parent=None): QTreeView.__init__(self, parent=None) @@ -297,7 +297,8 @@ class TagsView(QTreeView): # {{{ self.clear() def context_menu_handler(self, action=None, category=None, - key=None, index=None, search_state=None): + key=None, index=None, search_state=None, + use_vl=None): if not action: return try: @@ -328,11 +329,22 @@ class TagsView(QTreeView): # {{{ self.recount() return - if action == 'edit_item': + if action == 'edit_item_no_vl': + item = self.model().get_node(index) + item.use_vl = False self.edit(index) return - if action == 'delete_item': - self.tag_item_delete.emit(key, index.id, index.original_name) + if action == 'edit_item_in_vl': + item = self.model().get_node(index) + item.use_vl = True + self.edit(index) + return + if action == 'delete_item_in_vl': + self.tag_item_delete.emit(key, index.id, index.original_name, + self.model().get_book_ids_to_use()) + return + if action == 'delete_item_no_vl': + self.tag_item_delete.emit(key, index.id, index.original_name, None) return if action == 'open_editor': self.tags_list_edit.emit(category, key) @@ -441,15 +453,26 @@ class TagsView(QTreeView): # {{{ # the possibility of renaming that item. if tag.is_editable: # Add the 'rename' items + if self.model().get_in_vl(): + self.context_menu.addAction(self.rename_icon, + _('Rename %s in virtual library')%display_name(tag), + partial(self.context_menu_handler, action='edit_item_in_vl', + index=index)) self.context_menu.addAction(self.rename_icon, - _('Rename %s')%display_name(tag), - partial(self.context_menu_handler, action='edit_item', - index=index)) + _('Rename %s')%display_name(tag), + partial(self.context_menu_handler, action='edit_item_no_vl', + index=index)) if key in ('tags', 'series', 'publisher') or \ self._model.db.field_metadata.is_custom_field(key): + if self.model().get_in_vl(): + self.context_menu.addAction(self.delete_icon, + _('Delete %s in virtual library')%display_name(tag), + partial(self.context_menu_handler, action='delete_item_in_vl', + key=key, index=tag)) + self.context_menu.addAction(self.delete_icon, _('Delete %s')%display_name(tag), - partial(self.context_menu_handler, action='delete_item', + partial(self.context_menu_handler, action='delete_item_no_vl', key=key, index=tag)) if key == 'authors': self.context_menu.addAction(_('Edit sort for %s')%display_name(tag), @@ -482,7 +505,7 @@ class TagsView(QTreeView): # {{{ elif key == 'search' and tag.is_searchable: self.context_menu.addAction(self.rename_icon, _('Rename %s')%display_name(tag), - partial(self.context_menu_handler, action='edit_item', + partial(self.context_menu_handler, action='edit_item_no_vl', index=index)) self.context_menu.addAction(self.delete_icon, _('Delete search %s')%display_name(tag), @@ -512,7 +535,7 @@ class TagsView(QTreeView): # {{{ if item.can_be_edited: self.context_menu.addAction(self.rename_icon, _('Rename %s')%item.py_name, - partial(self.context_menu_handler, action='edit_item', + partial(self.context_menu_handler, action='edit_item_no_vl', index=index)) self.context_menu.addAction(self.user_category_icon, _('Add sub-category to %s')%item.py_name,