mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-08 10:44:09 -04:00
Tag Browser: Make rename and delete for items in the Tag Browser restrict themselves to the current Virtual Library (if any). There is also an additional menu entry you can use to rename and delete across all books while in a Virtual Library.
Merge branch 'master' of https://github.com/cbhaley/calibre
This commit is contained in:
commit
0ca49a65f1
@ -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})
|
||||
|
@ -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:
|
||||
|
@ -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): # {{{
|
||||
|
@ -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
|
||||
|
||||
|
@ -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='<p>'+
|
||||
_('%s will be deleted from all books. Are you sure?') %orig_name,
|
||||
msg='<p>'+ 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
|
||||
|
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user