Enhancement #1880264: Additional functionality for tag hierarchies

Two additions:
1) right-click "rename" on an intermediate node renames that node and all the children.
2) Drag & Drop inside a hierarchical category moves the source to the target

Important behavior changes:
1) deleting an item deletes that item and now in addition all its children.
2) renaming an item renames it and now in addition all its children.
This commit is contained in:
Charles Haley 2020-05-27 19:25:27 +01:00
parent d9f2449ddc
commit 161fcbbb9d
3 changed files with 183 additions and 42 deletions

View File

@ -59,6 +59,8 @@ class TagTreeItem(object): # {{{
self.blank = QIcon() self.blank = QIcon()
self.is_gst = False self.is_gst = False
self.boxed = False self.boxed = False
self.temporary = False
self.can_be_edited = False
self.icon_state_map = list(icon_map) self.icon_state_map = list(icon_map)
if self.parent is not None: if self.parent is not None:
self.parent.append(self) self.parent.append(self)
@ -113,9 +115,9 @@ class TagTreeItem(object): # {{{
if self.type == self.ROOT: if self.type == self.ROOT:
return 'ROOT' return 'ROOT'
if self.type == self.CATEGORY: if self.type == self.CATEGORY:
return 'CATEGORY(category_key={!r}, name={!r}, num_children={!r})'.format( return 'CATEGORY(category_key={!r}, name={!r}, num_children={!r}, temp={!r})'.format(
self.category_key, self.name, len(self.children)) self.category_key, self.name, len(self.children). self.temporary)
return 'TAG(name=%r)'%self.tag.name return 'TAG(name={!r}), temp={!r})'.format(self.tag.name, self.temporary)
def row(self): def row(self):
if self.parent is not None: if self.parent is not None:
@ -390,6 +392,7 @@ class TagsModel(QAbstractItemModel): # {{{
del node # Clear reference to node in the current frame del node # Clear reference to node in the current frame
self.node_map.clear() self.node_map.clear()
self.category_nodes = [] self.category_nodes = []
self.hierarchical_categories = {}
self.root_item = self.create_node(icon_map=self.icon_state_map) self.root_item = self.create_node(icon_map=self.icon_state_map)
self._rebuild_node_tree(state_map=state_map) self._rebuild_node_tree(state_map=state_map)
@ -538,10 +541,7 @@ class TagsModel(QAbstractItemModel): # {{{
top_level_component = 'z' + data[key][0].original_name top_level_component = 'z' + data[key][0].original_name
last_idx = -collapse last_idx = -collapse
category_is_hierarchical = not ( category_is_hierarchical = self.is_key_a_hierarchical_category(key)
key in ['authors', 'publisher', 'news', 'formats', 'rating'] or
key not in self.db.prefs.get('categories_using_hierarchy', []) or
config['sort_tags_by'] != 'name')
for idx,tag in enumerate(data[key]): for idx,tag in enumerate(data[key]):
components = None components = None
@ -573,7 +573,13 @@ class TagsModel(QAbstractItemModel): # {{{
d['first'] = ct2 d['first'] = ct2
else: else:
d = {'first': tag} d = {'first': tag}
# Some nodes like formats and identifiers don't
# have sort set. Fix that so the template will work
if d['first'].sort is None:
d['first'].sort = tag.name
d['last'] = data[key][last] d['last'] = data[key][last]
if d['last'].sort is None:
d['last'].sort = data[key][last].name
name = eval_formatter.safe_format(collapse_template, name = eval_formatter.safe_format(collapse_template,
d, '##TAG_VIEW##', None) d, '##TAG_VIEW##', None)
@ -716,6 +722,22 @@ class TagsModel(QAbstractItemModel): # {{{
p = p.parent p = p.parent
return p.tag.category.startswith('@') return p.tag.category.startswith('@')
def is_key_a_hierarchical_category(self, key):
if key in self.hierarchical_categories:
return self.hierarchical_categories[key]
result = not (
key in ['authors', 'publisher', 'news', 'formats', 'rating'] or
key not in self.db.prefs.get('categories_using_hierarchy', []) or
config['sort_tags_by'] != 'name')
self.hierarchical_categories[key] = result
return result
def is_index_on_a_hierarchical_category(self, index):
if not index.isValid():
return False
p = self.get_node(index)
return self.is_key_a_hierarchical_category(p.tag.category)
# Drag'n Drop {{{ # Drag'n Drop {{{
def mimeTypes(self): def mimeTypes(self):
return ["application/calibre+from_library", return ["application/calibre+from_library",
@ -760,15 +782,46 @@ class TagsModel(QAbstractItemModel): # {{{
if not parent.isValid(): if not parent.isValid():
return False return False
dest = self.get_node(parent) dest = self.get_node(parent)
if dest.type != TagTreeItem.CATEGORY:
return False
if not md.hasFormat('application/calibre+from_tag_browser'): if not md.hasFormat('application/calibre+from_tag_browser'):
return False return False
data = bytes(md.data('application/calibre+from_tag_browser')) data = bytes(md.data('application/calibre+from_tag_browser'))
src = json_loads(data) src = json_loads(data)
for s in src: if len(src) == 1:
# Check to see if this is a hierarchical rename
s = src[0]
# This check works for both hierarchical and user categories.
# We can drag only tag items.
if s[0] != TagTreeItem.TAG: if s[0] != TagTreeItem.TAG:
return False return False
src_index = self.index_for_path(s[5])
if src_index == parent:
# dropped on itself
return False
src_item = self.get_node(src_index)
dest_item = parent.data(Qt.UserRole)
# Here we do the real work. If src is a tag, src == dest, and src
# is hierarchical then we can do a rename.
if (src_item.type == TagTreeItem.TAG and
src_item.tag.category == dest_item.tag.category and
self.is_key_a_hierarchical_category(src_item.tag.category)):
key = s[1]
# work out the part of the source name to use in the rename
# It isn't necessarily a simple name but might be the remaining
# levels of the hierarchy
part = src_item.tag.original_name.rpartition('.')
src_simple_name = part[2]
# work out the new prefix, the destination node name
if dest.type == TagTreeItem.TAG:
new_name = dest_item.tag.original_name + '.' + src_simple_name
else:
new_name = src_simple_name
# In d&d renames always use the vl. This might be controversial.
src_item.use_vl = True
self.rename_item(src_item, key, new_name)
return True
# Should be working with a user category
if dest.type != TagTreeItem.CATEGORY:
return False
return self.move_or_copy_item_to_user_category(src, dest, action) return self.move_or_copy_item_to_user_category(src, dest, action)
def move_or_copy_item_to_user_category(self, src, dest, action): def move_or_copy_item_to_user_category(self, src, dest, action):
@ -1134,7 +1187,6 @@ class TagsModel(QAbstractItemModel): # {{{
return True return True
key = item.tag.category key = item.tag.category
name = item.tag.original_name
# make certain we know about the item's category # make certain we know about the item's category
if key not in self.db.field_metadata: if key not in self.db.field_metadata:
return False return False
@ -1153,19 +1205,46 @@ class TagsModel(QAbstractItemModel): # {{{
item.tag.name = val item.tag.name = val
self.search_item_renamed.emit() # Does a refresh self.search_item_renamed.emit() # Does a refresh
else: else:
self.use_position_based_index_on_next_recount = True self.rename_item(item, key, val)
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.use_position_based_index_on_next_recount = True
if not restrict_to_book_ids:
self.rename_item_in_all_user_categories(name, key, val)
self.refresh_required.emit()
return True return True
def rename_item(self, item, key, to_what):
def do_one_item(lookup_key, an_item, original_name, new_name, restrict_to_books):
self.use_position_based_index_on_next_recount = True
self.db.new_api.rename_items(lookup_key, {an_item.tag.id: new_name},
restrict_to_book_ids=restrict_to_books)
self.tag_item_renamed.emit()
an_item.tag.name = new_name
an_item.tag.state = TAG_SEARCH_STATES['clear']
self.use_position_based_index_on_next_recount = True
if not restrict_to_books:
self.rename_item_in_all_user_categories(original_name,
lookup_key, new_name)
children = item.all_children()
restrict_to_book_ids=self.get_book_ids_to_use() if item.use_vl else None
if item.tag.is_editable and len(children) == 0:
# Leaf node, just do it.
do_one_item(key, item, item.tag.original_name, to_what, restrict_to_book_ids)
else:
# Middle node of a hierarchy
search_name = item.tag.original_name
# Clear any search icons on the original tag
if item.parent.type == TagTreeItem.TAG:
item.parent.tag.state = TAG_SEARCH_STATES['clear']
# It might also be a leaf
if item.tag.is_editable:
do_one_item(key, item, item.tag.original_name, to_what, restrict_to_book_ids)
# Now do the children
for child_item in children:
from calibre.utils.icu import startswith
if (child_item.tag.is_editable and
startswith(child_item.tag.original_name, search_name)):
new_name = to_what + child_item.tag.original_name[len(search_name):]
do_one_item(key, child_item, child_item.tag.original_name,
new_name, restrict_to_book_ids)
self.refresh_required.emit()
def rename_item_in_all_user_categories(self, item_name, item_category, new_name): def rename_item_in_all_user_categories(self, item_name, item_category, new_name):
''' '''
Search all User categories for items named item_name with category Search all User categories for items named item_name with category
@ -1217,7 +1296,7 @@ class TagsModel(QAbstractItemModel): # {{{
if index.isValid(): if index.isValid():
node = self.data(index, Qt.UserRole) node = self.data(index, Qt.UserRole)
if node.type == TagTreeItem.TAG: if node.type == TagTreeItem.TAG:
if node.tag.is_editable: if node.tag.is_editable or node.tag.is_hierarchical:
ans |= Qt.ItemIsDragEnabled ans |= Qt.ItemIsDragEnabled
fm = self.db.metadata_for_field(node.tag.category) fm = self.db.metadata_for_field(node.tag.category)
if node.tag.category in \ if node.tag.category in \

View File

@ -278,21 +278,46 @@ class TagBrowserMixin(object): # {{{
self.do_tag_item_renamed() self.do_tag_item_renamed()
self.tags_view.recount() self.tags_view.recount()
def do_tag_item_delete(self, category, item_id, orig_name, restrict_to_book_ids=None): def do_tag_item_delete(self, category, item_id, orig_name,
restrict_to_book_ids=None, children=[]):
''' '''
Delete an item from some category. Delete an item from some category.
''' '''
tag_names = []
for child in children:
if child.tag.is_editable:
tag_names.append(child.tag.original_name)
n = '\n '.join(tag_names)
if n:
n = '%s:\n %s\n%s:\n %s'%(_('Item'), orig_name, _('Children'), n)
if n:
if restrict_to_book_ids:
msg = _('%s and its children will be deleted from books '
'in the Virtual library. Are you sure?')%orig_name
else:
msg = _('%s and its children will be deleted from all books. '
'Are you sure?')%orig_name
else:
if restrict_to_book_ids: if restrict_to_book_ids:
msg = _('%s will be deleted from books in the Virtual library. Are you sure?')%orig_name msg = _('%s will be deleted from books in the Virtual library. Are you sure?')%orig_name
else: else:
msg = _('%s will be deleted from all books. Are you sure?')%orig_name msg = _('%s will be deleted from all books. Are you sure?')%orig_name
if not question_dialog(self.tags_view, if not question_dialog(self.tags_view,
title=_('Delete item'), title=_('Delete item'),
msg='<p>'+ msg, msg='<p>'+ msg,
skip_dialog_name='tag_item_delete', det_msg=n,
# Change the skip name because functionality has greatly changed
skip_dialog_name='tag_item_delete_hierarchical',
skip_dialog_msg=_('Show this confirmation again')): skip_dialog_msg=_('Show this confirmation again')):
return return
self.current_db.new_api.remove_items(category, (item_id,), restrict_to_book_ids=restrict_to_book_ids) ids_to_remove = [item_id]
for child in children:
if child.tag.is_editable:
ids_to_remove.append(child.tag.id)
self.current_db.new_api.remove_items(category, ids_to_remove,
restrict_to_book_ids=restrict_to_book_ids)
if restrict_to_book_ids is None: if restrict_to_book_ids is None:
m = self.tags_view.model() m = self.tags_view.model()
m.delete_item_from_all_user_categories(orig_name, category) m.delete_item_from_all_user_categories(orig_name, category)

View File

@ -153,7 +153,7 @@ class TagsView(QTreeView): # {{{
search_item_renamed = pyqtSignal() search_item_renamed = pyqtSignal()
drag_drop_finished = pyqtSignal(object) drag_drop_finished = pyqtSignal(object)
restriction_error = pyqtSignal() restriction_error = pyqtSignal()
tag_item_delete = pyqtSignal(object, object, object, object) tag_item_delete = pyqtSignal(object, object, object, object, object)
apply_tag_to_selected = pyqtSignal(object, object, object) apply_tag_to_selected = pyqtSignal(object, object, object)
def __init__(self, parent=None): def __init__(self, parent=None):
@ -320,6 +320,11 @@ class TagsView(QTreeView): # {{{
except: except:
pass pass
def mousePressEvent(self, event):
if event.buttons() & Qt.LeftButton:
self.possible_drag_start = event.pos()
return QTreeView.mousePressEvent(self, event)
def mouseMoveEvent(self, event): def mouseMoveEvent(self, event):
dex = self.indexAt(event.pos()) dex = self.indexAt(event.pos())
if dex.isValid(): if dex.isValid():
@ -331,6 +336,11 @@ class TagsView(QTreeView): # {{{
if self.in_drag_drop or not dex.isValid(): if self.in_drag_drop or not dex.isValid():
QTreeView.mouseMoveEvent(self, event) QTreeView.mouseMoveEvent(self, event)
return return
# don't start drag/drop until the mouse has moved a bit.
if ((event.pos() - self.possible_drag_start).manhattanLength() <
QApplication.startDragDistance()):
QTreeView.mouseMoveEvent(self, event)
return
# Must deal with odd case where the node being dragged is 'virtual', # Must deal with odd case where the node being dragged is 'virtual',
# created to form a hierarchy. We can't really drag this node, but in # created to form a hierarchy. We can't really drag this node, but in
# addition we can't allow drag recognition to notice going over some # addition we can't allow drag recognition to notice going over some
@ -345,7 +355,14 @@ class TagsView(QTreeView): # {{{
drag = QDrag(self) drag = QDrag(self)
drag.setPixmap(pixmap) drag.setPixmap(pixmap)
drag.setMimeData(md) drag.setMimeData(md)
if self._model.is_in_user_category(dex): if (self._model.is_in_user_category(dex) or
self._model.is_index_on_a_hierarchical_category(dex)):
'''
Things break if we specify MoveAction as the default, which is
what we want for drag on hierarchical categories. Dragging user
categories stops working. Don't know why. To avoid the problem
we fix the action in dragMoveEvent.
'''
drag.exec_(Qt.CopyAction|Qt.MoveAction, Qt.CopyAction) drag.exec_(Qt.CopyAction|Qt.MoveAction, Qt.CopyAction)
else: else:
drag.exec_(Qt.CopyAction) drag.exec_(Qt.CopyAction)
@ -440,11 +457,17 @@ class TagsView(QTreeView): # {{{
self.edit(index) self.edit(index)
return return
if action == 'delete_item_in_vl': if action == 'delete_item_in_vl':
self.tag_item_delete.emit(key, index.id, index.original_name, tag = index.tag
self.model().get_book_ids_to_use()) children = index.child_tags()
self.tag_item_delete.emit(key, tag.id, tag.original_name,
self.model().get_book_ids_to_use(),
children)
return return
if action == 'delete_item_no_vl': if action == 'delete_item_no_vl':
self.tag_item_delete.emit(key, index.id, index.original_name, None) tag = index.tag
children = index.child_tags()
self.tag_item_delete.emit(key, tag.id, tag.original_name,
None, children)
return return
if action == 'open_editor': if action == 'open_editor':
self.tags_list_edit.emit(category, key, is_first_letter) self.tags_list_edit.emit(category, key, is_first_letter)
@ -550,6 +573,8 @@ class TagsView(QTreeView): # {{{
if len(n) > 45: if len(n) > 45:
n = n[:45] + '...' n = n[:45] + '...'
ans = "'" + n + "'" ans = "'" + n + "'"
elif tag.is_hierarchical and not tag.is_editable:
ans = tag.original_name
if ans: if ans:
ans = ans.replace('&', '&&') ans = ans.replace('&', '&&')
return ans return ans
@ -582,8 +607,8 @@ class TagsView(QTreeView): # {{{
if tag: if tag:
# If the user right-clicked on an editable item, then offer # If the user right-clicked on an editable item, then offer
# the possibility of renaming that item. # the possibility of renaming that item.
if tag.is_editable: if tag.is_editable or tag.is_hierarchical:
# Add the 'rename' items # Add the 'rename' items to both interior and leaf nodes
if self.model().get_in_vl(): if self.model().get_in_vl():
self.context_menu.addAction(self.rename_icon, self.context_menu.addAction(self.rename_icon,
_('Rename %s in Virtual library')%display_name(tag), _('Rename %s in Virtual library')%display_name(tag),
@ -593,18 +618,19 @@ class TagsView(QTreeView): # {{{
_('Rename %s')%display_name(tag), _('Rename %s')%display_name(tag),
partial(self.context_menu_handler, action='edit_item_no_vl', partial(self.context_menu_handler, action='edit_item_no_vl',
index=index, category=key)) index=index, category=key))
if tag.is_editable:
if key in ('tags', 'series', 'publisher') or \ if key in ('tags', 'series', 'publisher') or \
self._model.db.field_metadata.is_custom_field(key): self._model.db.field_metadata.is_custom_field(key):
if self.model().get_in_vl(): if self.model().get_in_vl():
self.context_menu.addAction(self.delete_icon, self.context_menu.addAction(self.delete_icon,
_('Delete %s in Virtual library')%display_name(tag), _('Delete %s in Virtual library')%display_name(tag),
partial(self.context_menu_handler, action='delete_item_in_vl', partial(self.context_menu_handler, action='delete_item_in_vl',
key=key, index=tag)) key=key, index=tag_item))
self.context_menu.addAction(self.delete_icon, self.context_menu.addAction(self.delete_icon,
_('Delete %s')%display_name(tag), _('Delete %s')%display_name(tag),
partial(self.context_menu_handler, action='delete_item_no_vl', partial(self.context_menu_handler, action='delete_item_no_vl',
key=key, index=tag)) key=key, index=tag_item))
if key == 'authors': if key == 'authors':
self.context_menu.addAction(_('Edit sort for %s')%display_name(tag), self.context_menu.addAction(_('Edit sort for %s')%display_name(tag),
partial(self.context_menu_handler, partial(self.context_menu_handler,
@ -841,8 +867,20 @@ class TagsView(QTreeView): # {{{
item = index.data(Qt.UserRole) item = index.data(Qt.UserRole)
if item.type == TagTreeItem.ROOT: if item.type == TagTreeItem.ROOT:
return return
flags = self._model.flags(index)
if item.type == TagTreeItem.TAG and flags & Qt.ItemIsDropEnabled: if src_is_tb:
src = json_loads(bytes(event.mimeData().data('application/calibre+from_tag_browser')))
if len(src) == 1:
src_item = self._model.get_node(self._model.index_for_path(src[0][5]))
if (src_item.type == TagTreeItem.TAG and
src_item.tag.category == item.tag.category and
not item.temporary and
self._model.is_key_a_hierarchical_category(src_item.tag.category)):
event.setDropAction(Qt.MoveAction)
self.setDropIndicatorShown(True)
return
if item.type == TagTreeItem.TAG and self._model.flags(index) & Qt.ItemIsDropEnabled:
event.setDropAction(Qt.CopyAction)
self.setDropIndicatorShown(not src_is_tb) self.setDropIndicatorShown(not src_is_tb)
return return
if item.type == TagTreeItem.CATEGORY and not item.is_gst: if item.type == TagTreeItem.CATEGORY and not item.is_gst:
@ -850,8 +888,7 @@ class TagsView(QTreeView): # {{{
if fm_dest['kind'] == 'user': if fm_dest['kind'] == 'user':
if src_is_tb: if src_is_tb:
if event.dropAction() == Qt.MoveAction: if event.dropAction() == Qt.MoveAction:
data = bytes(event.mimeData().data('application/calibre+from_tag_browser')) # src is initialized above
src = json_loads(data)
for s in src: for s in src:
if s[0] == TagTreeItem.TAG and \ if s[0] == TagTreeItem.TAG and \
(not s[1].startswith('@') or s[2]): (not s[1].startswith('@') or s[2]):