diff --git a/src/calibre/gui2/tag_browser/model.py b/src/calibre/gui2/tag_browser/model.py index 779511ff68..8f304e74a1 100644 --- a/src/calibre/gui2/tag_browser/model.py +++ b/src/calibre/gui2/tag_browser/model.py @@ -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 \ diff --git a/src/calibre/gui2/tag_browser/ui.py b/src/calibre/gui2/tag_browser/ui.py index 5cb3f0264f..4c75db9ad2 100644 --- a/src/calibre/gui2/tag_browser/ui.py +++ b/src/calibre/gui2/tag_browser/ui.py @@ -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='
'+ 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) diff --git a/src/calibre/gui2/tag_browser/view.py b/src/calibre/gui2/tag_browser/view.py index 11bbd3d1a6..a2643205d1 100644 --- a/src/calibre/gui2/tag_browser/view.py +++ b/src/calibre/gui2/tag_browser/view.py @@ -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]):