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.is_gst = False
self.boxed = False
self.temporary = False
self.can_be_edited = False
self.icon_state_map = list(icon_map)
if self.parent is not None:
self.parent.append(self)
@ -113,9 +115,9 @@ class TagTreeItem(object): # {{{
if self.type == self.ROOT:
return 'ROOT'
if self.type == self.CATEGORY:
return 'CATEGORY(category_key={!r}, name={!r}, num_children={!r})'.format(
self.category_key, self.name, len(self.children))
return 'TAG(name=%r)'%self.tag.name
return 'CATEGORY(category_key={!r}, name={!r}, num_children={!r}, temp={!r})'.format(
self.category_key, self.name, len(self.children). self.temporary)
return 'TAG(name={!r}), temp={!r})'.format(self.tag.name, self.temporary)
def row(self):
if self.parent is not None:
@ -390,6 +392,7 @@ class TagsModel(QAbstractItemModel): # {{{
del node # Clear reference to node in the current frame
self.node_map.clear()
self.category_nodes = []
self.hierarchical_categories = {}
self.root_item = self.create_node(icon_map=self.icon_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
last_idx = -collapse
category_is_hierarchical = 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')
category_is_hierarchical = self.is_key_a_hierarchical_category(key)
for idx,tag in enumerate(data[key]):
components = None
@ -573,7 +573,13 @@ class TagsModel(QAbstractItemModel): # {{{
d['first'] = ct2
else:
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]
if d['last'].sort is None:
d['last'].sort = data[key][last].name
name = eval_formatter.safe_format(collapse_template,
d, '##TAG_VIEW##', None)
@ -716,6 +722,22 @@ class TagsModel(QAbstractItemModel): # {{{
p = p.parent
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 {{{
def mimeTypes(self):
return ["application/calibre+from_library",
@ -760,15 +782,46 @@ class TagsModel(QAbstractItemModel): # {{{
if not parent.isValid():
return False
dest = self.get_node(parent)
if dest.type != TagTreeItem.CATEGORY:
return False
if not md.hasFormat('application/calibre+from_tag_browser'):
return False
data = bytes(md.data('application/calibre+from_tag_browser'))
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:
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)
def move_or_copy_item_to_user_category(self, src, dest, action):
@ -1134,7 +1187,6 @@ class TagsModel(QAbstractItemModel): # {{{
return True
key = item.tag.category
name = item.tag.original_name
# make certain we know about the item's category
if key not in self.db.field_metadata:
return False
@ -1153,19 +1205,46 @@ class TagsModel(QAbstractItemModel): # {{{
item.tag.name = val
self.search_item_renamed.emit() # Does a refresh
else:
self.use_position_based_index_on_next_recount = True
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()
self.rename_item(item, key, val)
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):
'''
Search all User categories for items named item_name with category
@ -1217,7 +1296,7 @@ class TagsModel(QAbstractItemModel): # {{{
if index.isValid():
node = self.data(index, Qt.UserRole)
if node.type == TagTreeItem.TAG:
if node.tag.is_editable:
if node.tag.is_editable or node.tag.is_hierarchical:
ans |= Qt.ItemIsDragEnabled
fm = self.db.metadata_for_field(node.tag.category)
if node.tag.category in \

View File

@ -278,21 +278,46 @@ class TagBrowserMixin(object): # {{{
self.do_tag_item_renamed()
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.
'''
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:
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>'+ 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')):
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:
m = self.tags_view.model()
m.delete_item_from_all_user_categories(orig_name, category)

View File

@ -153,7 +153,7 @@ class TagsView(QTreeView): # {{{
search_item_renamed = pyqtSignal()
drag_drop_finished = pyqtSignal(object)
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)
def __init__(self, parent=None):
@ -320,6 +320,11 @@ class TagsView(QTreeView): # {{{
except:
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):
dex = self.indexAt(event.pos())
if dex.isValid():
@ -331,6 +336,11 @@ class TagsView(QTreeView): # {{{
if self.in_drag_drop or not dex.isValid():
QTreeView.mouseMoveEvent(self, event)
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',
# 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
@ -345,7 +355,14 @@ class TagsView(QTreeView): # {{{
drag = QDrag(self)
drag.setPixmap(pixmap)
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)
else:
drag.exec_(Qt.CopyAction)
@ -440,11 +457,17 @@ class TagsView(QTreeView): # {{{
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())
tag = index.tag
children = index.child_tags()
self.tag_item_delete.emit(key, tag.id, tag.original_name,
self.model().get_book_ids_to_use(),
children)
return
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
if action == 'open_editor':
self.tags_list_edit.emit(category, key, is_first_letter)
@ -550,6 +573,8 @@ class TagsView(QTreeView): # {{{
if len(n) > 45:
n = n[:45] + '...'
ans = "'" + n + "'"
elif tag.is_hierarchical and not tag.is_editable:
ans = tag.original_name
if ans:
ans = ans.replace('&', '&&')
return ans
@ -582,8 +607,8 @@ class TagsView(QTreeView): # {{{
if tag:
# If the user right-clicked on an editable item, then offer
# the possibility of renaming that item.
if tag.is_editable:
# Add the 'rename' items
if tag.is_editable or tag.is_hierarchical:
# Add the 'rename' items to both interior and leaf nodes
if self.model().get_in_vl():
self.context_menu.addAction(self.rename_icon,
_('Rename %s in Virtual library')%display_name(tag),
@ -593,18 +618,19 @@ class TagsView(QTreeView): # {{{
_('Rename %s')%display_name(tag),
partial(self.context_menu_handler, action='edit_item_no_vl',
index=index, category=key))
if tag.is_editable:
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))
key=key, index=tag_item))
self.context_menu.addAction(self.delete_icon,
_('Delete %s')%display_name(tag),
partial(self.context_menu_handler, action='delete_item_no_vl',
key=key, index=tag))
key=key, index=tag_item))
if key == 'authors':
self.context_menu.addAction(_('Edit sort for %s')%display_name(tag),
partial(self.context_menu_handler,
@ -841,8 +867,20 @@ class TagsView(QTreeView): # {{{
item = index.data(Qt.UserRole)
if item.type == TagTreeItem.ROOT:
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)
return
if item.type == TagTreeItem.CATEGORY and not item.is_gst:
@ -850,8 +888,7 @@ class TagsView(QTreeView): # {{{
if fm_dest['kind'] == 'user':
if src_is_tb:
if event.dropAction() == Qt.MoveAction:
data = bytes(event.mimeData().data('application/calibre+from_tag_browser'))
src = json_loads(data)
# src is initialized above
for s in src:
if s[0] == TagTreeItem.TAG and \
(not s[1].startswith('@') or s[2]):