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:
Kovid Goyal 2015-03-08 16:17:02 +05:30
commit 0ca49a65f1
6 changed files with 241 additions and 70 deletions

View File

@ -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})

View File

@ -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:

View File

@ -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): # {{{

View File

@ -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

View File

@ -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

View File

@ -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,