mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-06-23 15:30:45 -04:00
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:
parent
d9f2449ddc
commit
161fcbbb9d
@ -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 \
|
||||
|
@ -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.
|
||||
'''
|
||||
if restrict_to_book_ids:
|
||||
msg = _('%s will be deleted from books in the Virtual library. Are you sure?')%orig_name
|
||||
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:
|
||||
msg = _('%s will be deleted from all books. Are you sure?')%orig_name
|
||||
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)
|
||||
|
@ -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]):
|
||||
|
Loading…
x
Reference in New Issue
Block a user