From f5f1c87cd58b85a4ccf9c0406e8f539de191bcc9 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 09:03:10 +0000 Subject: [PATCH 01/11] Several things: 1) can remove an item from a user category 2) can remove a user category 3) renaming an item will also rename the item in all user categories 4) make find_item_node work in the face of hierarchies (user cats and items) 5) change set_new_model() to recount() where possible to avoid closing expanded trees --- src/calibre/gui2/tag_view.py | 207 ++++++++++++++++++++++++++--------- 1 file changed, 156 insertions(+), 51 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index d5b62f8efc..449a94d011 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -25,7 +25,7 @@ from calibre.utils.config import tweaks from calibre.utils.icu import sort_key, upper, lower, strcmp from calibre.utils.search_query_parser import saved_searches from calibre.utils.formatter import eval_formatter -from calibre.gui2 import error_dialog +from calibre.gui2 import error_dialog, question_dialog from calibre.gui2.dialogs.confirm_delete import confirm from calibre.gui2.dialogs.tag_categories import TagCategories from calibre.gui2.dialogs.tag_list_editor import TagListEditor @@ -73,6 +73,8 @@ class TagsView(QTreeView): # {{{ refresh_required = pyqtSignal() tags_marked = pyqtSignal(object) user_category_edit = pyqtSignal(object) + user_category_delete= pyqtSignal(object) + del_user_cat_item = pyqtSignal(object, object, object) add_subcategory = pyqtSignal(object) tag_list_edit = pyqtSignal(object, object) saved_search_edit = pyqtSignal(object) @@ -105,6 +107,7 @@ class TagsView(QTreeView): # {{{ else: self.collapse_model = gprefs['tags_browser_partition_method'] self.search_icon = QIcon(I('search.png')) + self.user_category_icon = QIcon(I('tb_folder.png')) def set_pane_is_visible(self, to_what): pv = self.pane_is_visible @@ -220,15 +223,21 @@ class TagsView(QTreeView): # {{{ if action == 'manage_categories': self.user_category_edit.emit(category) return - if action == 'add_subcategory': - self.add_subcategory.emit(category) - return if action == 'search': self._toggle(index, set_to=search_state) return + if action == 'add_subcategory': + self.add_subcategory.emit(key) + return if action == 'search_category': self.tags_marked.emit(key + ':' + search_state) return + if action == 'delete_user_category': + self.user_category_delete.emit(key) + return + if action == 'delete_item_from_user_category': + self.del_user_cat_item.emit(key, index.name, index.category) + return if action == 'manage_searches': self.saved_search_edit.emit(category) return @@ -259,14 +268,11 @@ class TagsView(QTreeView): # {{{ if index.isValid(): item = index.internalPointer() - tag_name = '' + tag = None if item.type == TagTreeItem.TAG: - tag_item = item - t = item.tag - tag_name = t.name - tag_id = t.id - can_edit = getattr(t, 'can_edit', True) + tag = item.tag + can_edit = getattr(tag, 'can_edit', True) while item.type != TagTreeItem.CATEGORY: item = item.parent @@ -281,42 +287,50 @@ class TagsView(QTreeView): # {{{ return True # Did the user click on a leaf node? - if tag_name: + if tag: # If the user right-clicked on an editable item, then offer # the possibility of renaming that item. - if can_edit and \ - key in ['authors', 'tags', 'series', 'publisher', 'search'] or \ - (self.db.field_metadata[key]['is_custom'] and \ - self.db.field_metadata[key]['datatype'] != 'rating'): + if can_edit: # Add the 'rename' items - self.context_menu.addAction(_('Rename %s')%tag_name, + self.context_menu.addAction(_('Rename %s')%tag.name, partial(self.context_menu_handler, action='edit_item', - category=tag_item, index=index)) + index=index)) if key == 'authors': - self.context_menu.addAction(_('Edit sort for %s')%tag_name, + self.context_menu.addAction(_('Edit sort for %s')%tag.name, partial(self.context_menu_handler, - action='edit_author_sort', index=tag_id)) + action='edit_author_sort', index=tag.id)) + if key.startswith('@'): + self.context_menu.addAction(self.user_category_icon, + _('Remove %s from category %s')%(tag.name, item.py_name), + partial(self.context_menu_handler, + action='delete_item_from_user_category', + key = key, index = tag)) # Add the search for value items self.context_menu.addAction(self.search_icon, - _('Search for %s')%tag_name, + _('Search for %s')%tag.name, partial(self.context_menu_handler, action='search', search_state=TAG_SEARCH_STATES['mark_plus'], index=index)) self.context_menu.addAction(self.search_icon, - _('Search for everything but %s')%tag_name, + _('Search for everything but %s')%tag.name, partial(self.context_menu_handler, action='search', search_state=TAG_SEARCH_STATES['mark_minus'], index=index)) self.context_menu.addSeparator() elif key.startswith('@'): if item.can_edit: - self.context_menu.addAction(_('Rename %s')%key[1:], + self.context_menu.addAction(self.user_category_icon, + _('Rename %s')%item.py_name, partial(self.context_menu_handler, action='edit_item', - category=key, index=index)) - self.context_menu.addAction(self.search_icon, - _('Add sub-category to %s')%key[1:], + index=index)) + self.context_menu.addAction(self.user_category_icon, + _('Add sub-category to %s')%item.py_name, partial(self.context_menu_handler, - action='add_subcategory', category=key)) + action='add_subcategory', key=key)) + self.context_menu.addAction(self.user_category_icon, + _('Delete user category %s')%item.py_name, + partial(self.context_menu_handler, + action='delete_user_category', key=key)) self.context_menu.addSeparator() # Hide/Show/Restore categories if not key.startswith('@') or key.find('.') < 0: @@ -345,14 +359,14 @@ class TagsView(QTreeView): # {{{ self.db.field_metadata[key]['is_custom']: self.context_menu.addAction(_('Manage %s')%category, partial(self.context_menu_handler, action='open_editor', - category=tag_name, key=key)) + category=tag.name if tag else None, key=key)) elif key == 'authors': self.context_menu.addAction(_('Manage %s')%category, partial(self.context_menu_handler, action='edit_author_sort')) elif key == 'search': self.context_menu.addAction(_('Manage Saved Searches'), partial(self.context_menu_handler, action='manage_searches', - category=tag_name)) + category=tag.name if tag else None)) # Always show the user categories editor self.context_menu.addSeparator() @@ -534,6 +548,8 @@ class TagTreeItem(object): # {{{ def category_data(self, role): if role == Qt.DisplayRole: return QVariant(self.py_name + ' [%d]'%len(self.child_tags())) + if role == Qt.EditRole: + return QVariant(self.py_name) if role == Qt.DecorationRole: return self.icon if role == Qt.FontRole: @@ -727,8 +743,19 @@ class TagsModel(QAbstractItemModel): # {{{ for s in src: if s[0] != TagTreeItem.TAG: 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): + ''' + src is a list of tuples representing items to copy. The tuple is + (type, containing category key, full name, category key, id) + The type must be TagTreeItem.TAG + dest is the TagTreeItem node to receive the items + action is Qt.CopyAction or Qt.MoveAction + ''' user_cats = self.db.prefs.get('user_categories', {}) parent_node = None + copied_node = None for s in src: src_parent, src_name, src_cat = s[1:4] parent_node = src_parent @@ -748,6 +775,9 @@ class TagsModel(QAbstractItemModel): # {{{ continue new_cat.append(list(tup)) user_cats[src_parent] = new_cat + else: + copied_node = (src_parent, src_name) + # Now add the item to the destination user category add_it = True if not is_uc and src_cat == 'news': @@ -757,12 +787,17 @@ class TagsModel(QAbstractItemModel): # {{{ add_it = False if add_it: user_cats[dest_key].append([src_name, src_cat, 0]) + self.db.prefs.set('user_categories', user_cats) - self.tags_view.set_new_model() + self.tags_view.recount() + if parent_node is not None: - # Must work with the new model here m = self.tags_view.model() - path = m.find_category_node(parent_node) + if copied_node is not None: + path = m.find_item_node(parent_node, copied_node[1], None, + equals_match=True) + else: + path = m.find_category_node(parent_node) idx = m.index_for_path(path) self.tags_view.setExpanded(idx, True) m.show_item_at_index(idx) @@ -1031,15 +1066,17 @@ class TagsModel(QAbstractItemModel): # {{{ node_parent = category components = [t for t in tag.name.split('.')] - if key in ['authors', 'publisher', 'news', 'formats'] or \ + if key in ['authors', 'publisher', 'news', 'formats', 'rating'] or \ key not in self.db.prefs.get('categories_using_hierarchy', []) or\ len(components) == 1 or \ - fm['kind'] == 'user' or \ - fm['datatype'] not in ['text', 'series', 'enumeration']: + fm['kind'] == 'user': self.beginInsertRows(category_index, 999999, 1) TagTreeItem(parent=node_parent, data=tag, tooltip=tt, icon_map=self.icon_state_map) self.endInsertRows() + tag.can_edit = key != 'formats' and \ + self.db.field_metadata[tag.category]['datatype'] in \ + ['text', 'series', 'enumeration'] else: for i,comp in enumerate(components): child_map = dict([(t.tag.name, t) for t in node_parent.children @@ -1137,10 +1174,9 @@ class TagsModel(QAbstractItemModel): # {{{ p = self.tags_view.model().find_category_node('@' + nkey) self.tags_view.model().show_item_at_path(p) return True - itm = item.parent - while itm.type != TagTreeItem.CATEGORY: - itm = itm.parent - key = itm.category_key + + key = item.tag.category + name = item.tag.name # make certain we know about the item's category if key not in self.db.field_metadata: return False @@ -1171,6 +1207,17 @@ class TagsModel(QAbstractItemModel): # {{{ label=self.db.field_metadata[key]['label']) self.tags_view.tag_item_renamed.emit() item.tag.name = val + # rename the item in any user categories + user_cats = self.db.prefs.get('user_categories', {}) + for k in user_cats.keys(): + new_contents = [] + for tup in user_cats[k]: + if tup[0] == name and tup[1] == key: + new_contents.append([val, key, 0]) + else: + new_contents.append(tup) + user_cats[k] = new_contents + self.db.prefs.set('user_categories', user_cats) self.refresh() # Should work, because no categories can have disappeared self.show_item_at_path(path) return True @@ -1329,19 +1376,20 @@ class TagsModel(QAbstractItemModel): # {{{ name.replace(r'"', r'\"'))) return ans - def find_item_node(self, key, txt, start_path): + def find_item_node(self, key, txt, start_path, equals_match=False): ''' Search for an item (a node) in the tags browser list that matches both - the key (exact case-insensitive match) and txt (contains case- - insensitive match). Returns the path to the node. Note that paths are to - a location (second item, fourth item, 25 item), not to a node. If + the key (exact case-insensitive match) and txt (not equals_match => + case-insensitive contains match; equals_match => case_insensitive + equal match). Returns the path to the node. Note that paths are to a + location (second item, fourth item, 25 item), not to a node. If start_path is None, the search starts with the topmost node. If the tree is changed subsequent to calling this method, the path can easily refer to a different node or no node at all. ''' if not txt: return None - txt = lower(txt) + txt = lower(txt) if not equals_match else txt self.path_found = None if start_path is None: start_path = [] @@ -1353,7 +1401,9 @@ class TagsModel(QAbstractItemModel): # {{{ tag = tag_item.tag if tag is None: return False - if lower(tag.name).find(txt) >= 0: + name = getattr(tag, 'original_name', tag.name) + if (equals_match and strcmp(name, txt) == 0) or \ + (not equals_match and lower(name).find(txt) >= 0): self.path_found = path return True return False @@ -1365,15 +1415,14 @@ class TagsModel(QAbstractItemModel): # {{{ return False if path[depth] > start_path[depth]: start_path = path - if key and strcmp(category_index.internalPointer().category_key, key) != 0: - return False + my_key = category_index.internalPointer().category_key for j in xrange(self.rowCount(category_index)): tag_index = self.index(j, 0, category_index) tag_item = tag_index.internalPointer() if tag_item.type == TagTreeItem.CATEGORY: if process_level(depth+1, tag_index, start_path): return True - else: + elif not key or strcmp(key, my_key) == 0: if process_tag(depth+1, tag_index, tag_item, start_path): return True return False @@ -1457,6 +1506,8 @@ class TagBrowserMixin(object): # {{{ self.tags_view.tags_marked.connect(self.search.set_search_string) self.tags_view.tag_list_edit.connect(self.do_tags_list_edit) self.tags_view.user_category_edit.connect(self.do_user_categories_edit) + self.tags_view.user_category_delete.connect(self.do_user_category_delete) + self.tags_view.del_user_cat_item.connect(self.do_del_user_cat_item) self.tags_view.add_subcategory.connect(self.do_add_subcategory) self.tags_view.saved_search_edit.connect(self.do_saved_search_edit) self.tags_view.author_sort_edit.connect(self.do_author_sort_edit) @@ -1466,7 +1517,29 @@ class TagBrowserMixin(object): # {{{ self.edit_categories.clicked.connect(lambda x: self.do_user_categories_edit()) - def do_add_subcategory(self, on_category=None): + def do_del_user_cat_item(self, user_cat, item_name, item_category): + ''' + Delete the item (item_name, item_category) from the user category with + key user_cat. Any leading '@' characters are removed + ''' + if user_cat.startswith('@'): + user_cat = user_cat[1:] + db = self.library_view.model().db + user_cats = db.prefs.get('user_categories', {}) + if user_cat not in user_cats: + error_dialog(self.tags_view, _('Remove category'), + _('User category %s does not exist')%user_cat, + show=True) + return + new_contents = [] + for tup in user_cats[user_cat]: + if tup[0] != item_name or tup[1] != item_category: + new_contents.append(tup) + user_cats[user_cat] = new_contents + db.prefs.set('user_categories', user_cats) + self.tags_view.recount() + + def do_add_subcategory(self, on_category_key=None): db = self.library_view.model().db user_cats = db.prefs.get('user_categories', {}) @@ -1475,7 +1548,7 @@ class TagBrowserMixin(object): # {{{ new_name = _('New Category').replace('.', '') n = new_name while True: - new_cat = on_category[1:] + '.' + n + new_cat = on_category_key[1:] + '.' + n if new_cat not in user_cats: break i += 1 @@ -1500,7 +1573,39 @@ class TagBrowserMixin(object): # {{{ db.field_metadata.add_user_category('@' + k, k) db.data.change_search_locations(db.field_metadata.get_search_terms()) self.tags_view.set_new_model() - self.tags_view.recount() + + def do_user_category_delete(self, category_name): + ''' + Delete the user category named category_name. Any leading '@' is removed + ''' + if category_name.startswith('@'): + category_name = category_name[1:] + db = self.library_view.model().db + user_cats = db.prefs.get('user_categories', {}) + cat_keys = sorted(user_cats.keys(), key=sort_key) + has_children = False + found = False + for k in cat_keys: + if k == category_name: + found = True + has_children = len(user_cats[k]) + elif k.startswith(category_name + '.'): + has_children = True + if not found: + return error_dialog(self.tags_view, _('Delete user category'), + _('%s is not a user category')%category_name, show=True) + if has_children: + if not question_dialog(self.tags_view, _('Delete user category'), + _('%s contains items. Do you really ' + 'want to delete it?')%category_name): + return + for k in cat_keys: + if k == category_name: + del user_cats[k] + elif k.startswith(category_name + '.'): + del user_cats[k] + db.prefs.set('user_categories', user_cats) + self.tags_view.set_new_model() def do_tags_list_edit(self, tag, category): db=self.library_view.model().db @@ -1548,7 +1653,7 @@ class TagBrowserMixin(object): # {{{ # Clean up the library view self.do_tag_item_renamed() - self.tags_view.set_new_model() # does a refresh for free + self.tags_view.set_new_model() def do_tag_item_renamed(self): # Clean up library view and search From 91ed569caa6cd1941bf6209f0de5ce5b6c03f6a8 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 09:47:57 +0000 Subject: [PATCH 02/11] Rename and delete items in user categories after a 'manage X' operation --- src/calibre/gui2/dialogs/tag_list_editor.py | 2 + src/calibre/gui2/tag_view.py | 71 +++++++++++++++------ 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/src/calibre/gui2/dialogs/tag_list_editor.py b/src/calibre/gui2/dialogs/tag_list_editor.py index 6c3ebb22d5..cee9eb42b9 100644 --- a/src/calibre/gui2/dialogs/tag_list_editor.py +++ b/src/calibre/gui2/dialogs/tag_list_editor.py @@ -58,10 +58,12 @@ class TagListEditor(QDialog, Ui_TagListEditor): self.to_rename = {} self.to_delete = set([]) + self.original_names = {} self.all_tags = {} for k,v in data: self.all_tags[v] = k + self.original_names[k] = v for tag in sorted(self.all_tags.keys(), key=key): item = ListWidgetItem(tag) item.setData(Qt.UserRole, self.all_tags[tag]) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 449a94d011..01cda54afe 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -1207,21 +1207,54 @@ class TagsModel(QAbstractItemModel): # {{{ label=self.db.field_metadata[key]['label']) self.tags_view.tag_item_renamed.emit() item.tag.name = val - # rename the item in any user categories - user_cats = self.db.prefs.get('user_categories', {}) - for k in user_cats.keys(): - new_contents = [] - for tup in user_cats[k]: - if tup[0] == name and tup[1] == key: - new_contents.append([val, key, 0]) - else: - new_contents.append(tup) - user_cats[k] = new_contents - self.db.prefs.set('user_categories', user_cats) + self.rename_item_in_all_user_categories(name, key, val) self.refresh() # Should work, because no categories can have disappeared self.show_item_at_path(path) return True + 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 + item_category and rename them to new_name. The caller must arrange to + redisplay the tree as appropriate (recount or set_new_model) + ''' + user_cats = self.db.prefs.get('user_categories', {}) + for k in user_cats.keys(): + new_contents = [] + for tup in user_cats[k]: + if tup[0] == item_name and tup[1] == item_category: + new_contents.append([new_name, item_category, 0]) + else: + new_contents.append(tup) + user_cats[k] = new_contents + self.db.prefs.set('user_categories', user_cats) + + def delete_item_from_all_user_categories(self, item_name, item_category): + ''' + Search all user categories for items named item_name with category + item_category and delete them. The caller must arrange to redisplay the + tree as appropriate (recount or set_new_model) + ''' + user_cats = self.db.prefs.get('user_categories', {}) + for cat in user_cats.keys(): + self.delete_item_from_user_category(cat, item_name, item_category, + user_categories=user_cats) + self.db.prefs.set('user_categories', user_cats) + + def delete_item_from_user_category(self, category, item_name, item_category, + user_categories=None): + if user_categories is not None: + user_cats = user_categories + else: + user_cats = self.db.prefs.get('user_categories', {}) + new_contents = [] + for tup in user_cats[category]: + if tup[0] != item_name or tup[1] != item_category: + new_contents.append(tup) + user_cats[category] = new_contents + if user_categories is None: + self.db.prefs.set('user_categories', user_cats) + def headerData(self, *args): return NONE @@ -1531,12 +1564,8 @@ class TagBrowserMixin(object): # {{{ _('User category %s does not exist')%user_cat, show=True) return - new_contents = [] - for tup in user_cats[user_cat]: - if tup[0] != item_name or tup[1] != item_category: - new_contents.append(tup) - user_cats[user_cat] = new_contents - db.prefs.set('user_categories', user_cats) + self.tags_view.model().delete_item_from_user_category(user_cat, + item_name, item_category) self.tags_view.recount() def do_add_subcategory(self, on_category_key=None): @@ -1632,6 +1661,8 @@ class TagBrowserMixin(object): # {{{ if d.result() == d.Accepted: to_rename = d.to_rename # dict of new text to old id to_delete = d.to_delete # list of ids + orig_name = d.original_names # dict of id: name + rename_func = None if category == 'tags': rename_func = db.rename_tag @@ -1645,15 +1676,19 @@ class TagBrowserMixin(object): # {{{ else: rename_func = partial(db.rename_custom_item, label=cc_label) delete_func = partial(db.delete_custom_item_using_id, label=cc_label) + m = self.tags_view.model() if rename_func: for item in to_delete: delete_func(item) + m.delete_item_from_all_user_categories(orig_name[item], category) for old_id in to_rename: rename_func(old_id, new_name=unicode(to_rename[old_id])) + m.rename_item_in_all_user_categories(orig_name[old_id], + category, unicode(to_rename[old_id])) # Clean up the library view self.do_tag_item_renamed() - self.tags_view.set_new_model() + self.tags_view.recount() def do_tag_item_renamed(self): # Clean up library view and search From 9626ccab2b5005af52d1ba163ff587d2b09584b7 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 10:24:05 +0000 Subject: [PATCH 03/11] Fix some obscure cases: 1) cannot move items from a global search term user category 2) cannot remove items from a global search term user category 3) cannot rename global search term categories 4) ensure tags in the news category are editable 5) ensure renaming in a user category works with hierarchical items --- src/calibre/gui2/tag_view.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 01cda54afe..2c5864920e 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -299,7 +299,7 @@ class TagsView(QTreeView): # {{{ self.context_menu.addAction(_('Edit sort for %s')%tag.name, partial(self.context_menu_handler, action='edit_author_sort', index=tag.id)) - if key.startswith('@'): + if key.startswith('@') and not item.is_gst: self.context_menu.addAction(self.user_category_icon, _('Remove %s from category %s')%(tag.name, item.py_name), partial(self.context_menu_handler, @@ -317,7 +317,7 @@ class TagsView(QTreeView): # {{{ search_state=TAG_SEARCH_STATES['mark_minus'], index=index)) self.context_menu.addSeparator() - elif key.startswith('@'): + elif key.startswith('@') and not item.is_gst: if item.can_edit: self.context_menu.addAction(self.user_category_icon, _('Rename %s')%item.py_name, @@ -427,7 +427,8 @@ class TagsView(QTreeView): # {{{ data = str(event.mimeData().data('application/calibre+from_tag_browser')) src = cPickle.loads(data) for s in src: - if s[0] == TagTreeItem.TAG and not s[1].startswith('@'): + if s[0] == TagTreeItem.TAG and \ + (not s[1].startswith('@') or s[2]): return self.setDropIndicatorShown(True) return @@ -653,8 +654,10 @@ class TagsModel(QAbstractItemModel): # {{{ for i, r in enumerate(self.row_map): if self.hidden_categories and self.categories[i] in self.hidden_categories: continue + is_gst = False if r.startswith('@') and r[1:] in gst: tt = _(u'The grouped search term name is "{0}"').format(r[1:]) + is_gst = True elif r == 'news': tt = '' else: @@ -675,7 +678,8 @@ class TagsModel(QAbstractItemModel): # {{{ last_category_node = node category_node_map[path] = node self.category_nodes.append(node) - node.can_edit = i == (len(path_parts) - 1) + node.can_edit = (not is_gst) and (i == (len(path_parts)-1)) + node.is_gst = is_gst else: last_category_node = category_node_map[path] path += '.' @@ -684,6 +688,7 @@ class TagsModel(QAbstractItemModel): # {{{ data=self.categories[i], category_icon=self.category_icon_map[r], tooltip=tt, category_key=r) + node.is_gst = False category_node_map[r] = node last_category_node = node self.category_nodes.append(node) @@ -709,7 +714,7 @@ class TagsModel(QAbstractItemModel): # {{{ p = node while p.type != TagTreeItem.CATEGORY: p = p.parent - d = (node.type, p.category_key, + d = (node.type, p.category_key, p.is_gst, getattr(t, 'original_name', t.name), t.category, t.id) data.append(d) else: @@ -748,7 +753,8 @@ class TagsModel(QAbstractItemModel): # {{{ def move_or_copy_item_to_user_category(self, src, dest, action): ''' src is a list of tuples representing items to copy. The tuple is - (type, containing category key, full name, category key, id) + (type, containing category key, category key is global search term, + full name, category key, id) The type must be TagTreeItem.TAG dest is the TagTreeItem node to receive the items action is Qt.CopyAction or Qt.MoveAction @@ -757,7 +763,7 @@ class TagsModel(QAbstractItemModel): # {{{ parent_node = None copied_node = None for s in src: - src_parent, src_name, src_cat = s[1:4] + src_parent, src_parent_is_gst, src_name, src_cat = s[1:5] parent_node = src_parent if src_parent.startswith('@'): is_uc = True @@ -769,7 +775,8 @@ class TagsModel(QAbstractItemModel): # {{{ continue new_cat = [] # delete the item if the source is a user category and action is move - if is_uc and src_parent in user_cats and action == Qt.MoveAction: + if is_uc and not src_parent_is_gst and src_parent in user_cats and \ + action == Qt.MoveAction: for tup in user_cats[src_parent]: if src_name == tup[0] and src_cat == tup[1]: continue @@ -1074,9 +1081,9 @@ class TagsModel(QAbstractItemModel): # {{{ TagTreeItem(parent=node_parent, data=tag, tooltip=tt, icon_map=self.icon_state_map) self.endInsertRows() - tag.can_edit = key != 'formats' and \ + tag.can_edit = key != 'formats' and (key == 'news' or \ self.db.field_metadata[tag.category]['datatype'] in \ - ['text', 'series', 'enumeration'] + ['text', 'series', 'enumeration']) else: for i,comp in enumerate(components): child_map = dict([(t.tag.name, t) for t in node_parent.children @@ -1176,7 +1183,7 @@ class TagsModel(QAbstractItemModel): # {{{ return True key = item.tag.category - name = item.tag.name + name = getattr(item.tag, 'original_name', item.tag.name) # make certain we know about the item's category if key not in self.db.field_metadata: return False From 38a3d25562384115686ec1746d111a1e8036be1c Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 10:27:30 +0000 Subject: [PATCH 04/11] prevent dropping items on global search term user categories --- src/calibre/gui2/tag_view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 2c5864920e..c2949f777a 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -419,7 +419,7 @@ class TagsView(QTreeView): # {{{ if item.type == TagTreeItem.TAG and flags & Qt.ItemIsDropEnabled: self.setDropIndicatorShown(not src_is_tb) return - if item.type == TagTreeItem.CATEGORY: + if item.type == TagTreeItem.CATEGORY and not item.is_gst: fm_dest = self.db.metadata_for_field(item.category_key) if fm_dest['kind'] == 'user': if src_is_tb: From a8c278d44960b02ae0d74f1fa1b84949ca80ae1e Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 10:50:38 +0000 Subject: [PATCH 05/11] Fix problem with restoring the tree position when dropping hierarchical items onto user categories --- src/calibre/gui2/tag_view.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index c2949f777a..d53f510e55 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -1446,6 +1446,9 @@ class TagsModel(QAbstractItemModel): # {{{ (not equals_match and lower(name).find(txt) >= 0): self.path_found = path return True + for i,c in enumerate(tag_item.children): + if process_tag(depth+1, self.createIndex(i, 0, c), c, start_path): + return True return False def process_level(depth, category_index, start_path): From 762f6f255766144bfbafeb3ca81e0bdb670001de Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 10:56:28 +0000 Subject: [PATCH 06/11] Fix right-click removal of hierarchical items from user categories --- src/calibre/gui2/tag_view.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index d53f510e55..235251f8ea 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -236,7 +236,8 @@ class TagsView(QTreeView): # {{{ self.user_category_delete.emit(key) return if action == 'delete_item_from_user_category': - self.del_user_cat_item.emit(key, index.name, index.category) + self.del_user_cat_item.emit(key, + getattr(index, 'original_name', index.name), index.category) return if action == 'manage_searches': self.saved_search_edit.emit(category) From 9f7034f893cdab461998080f961a4aa0bbffbec5 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 11:06:48 +0000 Subject: [PATCH 07/11] Correct problem searching for hierarchical items when parent nodes exist as real items. --- src/calibre/gui2/tag_view.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 235251f8ea..39135fec27 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -1092,6 +1092,7 @@ class TagsModel(QAbstractItemModel): # {{{ if comp in child_map: node_parent = child_map[comp] node_parent.tag.count += tag.count + node_parent.tag.use_prefix = True else: if i < len(components)-1: t = copy.copy(tag) From c2a1bd7a4c2a47057ef71a7ac631ec8b8d4cbdc3 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 14:04:22 +0000 Subject: [PATCH 08/11] Add a right-click option to send (copy) an item to a user category. Clean up some names and documentation --- src/calibre/gui2/tag_view.py | 158 ++++++++++++++++++++++++++--------- 1 file changed, 118 insertions(+), 40 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 39135fec27..804fb503d1 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -70,18 +70,19 @@ TAG_SEARCH_STATES = {'clear': 0, 'mark_plus': 1, 'mark_minus': 2} class TagsView(QTreeView): # {{{ - refresh_required = pyqtSignal() - tags_marked = pyqtSignal(object) - user_category_edit = pyqtSignal(object) - user_category_delete= pyqtSignal(object) - del_user_cat_item = pyqtSignal(object, object, object) - add_subcategory = pyqtSignal(object) - tag_list_edit = pyqtSignal(object, object) - saved_search_edit = pyqtSignal(object) - author_sort_edit = pyqtSignal(object, object) - tag_item_renamed = pyqtSignal() - search_item_renamed = pyqtSignal() - drag_drop_finished = pyqtSignal(object, object) + refresh_required = pyqtSignal() + tags_marked = pyqtSignal(object) + edit_user_category = pyqtSignal(object) + delete_user_category = pyqtSignal(object) + del_item_from_user_cat = pyqtSignal(object, object, object) + add_item_to_user_cat = pyqtSignal(object, object, object) + add_subcategory = pyqtSignal(object) + tag_list_edit = pyqtSignal(object, object) + saved_search_edit = pyqtSignal(object) + author_sort_edit = pyqtSignal(object, object) + tag_item_renamed = pyqtSignal() + search_item_renamed = pyqtSignal() + drag_drop_finished = pyqtSignal(object, object) def __init__(self, parent=None): QTreeView.__init__(self, parent=None) @@ -221,11 +222,16 @@ class TagsView(QTreeView): # {{{ self.tag_list_edit.emit(category, key) return if action == 'manage_categories': - self.user_category_edit.emit(category) + self.edit_user_category.emit(category) return if action == 'search': self._toggle(index, set_to=search_state) return + if action == 'add_to_category': + self.add_item_to_user_cat.emit(category, + getattr(index, 'original_name', index.name), + index.category) + return if action == 'add_subcategory': self.add_subcategory.emit(key) return @@ -233,10 +239,10 @@ class TagsView(QTreeView): # {{{ self.tags_marked.emit(key + ':' + search_state) return if action == 'delete_user_category': - self.user_category_delete.emit(key) + self.delete_user_category.emit(key) return if action == 'delete_item_from_user_category': - self.del_user_cat_item.emit(key, + self.del_item_from_user_cat.emit(key, getattr(index, 'original_name', index.name), index.category) return if action == 'manage_searches': @@ -300,6 +306,25 @@ class TagsView(QTreeView): # {{{ self.context_menu.addAction(_('Edit sort for %s')%tag.name, partial(self.context_menu_handler, action='edit_author_sort', index=tag.id)) + m = self.context_menu.addMenu(self.user_category_icon, + _('Add %s to user category')%tag.name) + nt = self.model().category_node_tree + def add_node_tree(tree_dict, m, path): + p = path[:] + for k in sorted(tree_dict.keys(), key=sort_key): + p.append(k) + m.addAction(self.user_category_icon, k, + partial(self.context_menu_handler, + 'add_to_category', + category='.'.join(p), + index=tag)) + if len(tree_dict[k]): + tm = m.addMenu(self.user_category_icon, + _('Children of %s')%k) + add_node_tree(tree_dict[k], tm, p) + p.pop() + add_node_tree(nt, m, []) + if key.startswith('@') and not item.is_gst: self.context_menu.addAction(self.user_category_icon, _('Remove %s from category %s')%(tag.name, item.py_name), @@ -652,6 +677,7 @@ class TagsModel(QAbstractItemModel): # {{{ last_category_node = None category_node_map = {} + self.category_node_tree = {} for i, r in enumerate(self.row_map): if self.hidden_categories and self.categories[i] in self.hidden_categories: continue @@ -668,6 +694,7 @@ class TagsModel(QAbstractItemModel): # {{{ path_parts = [p.strip() for p in r.split('.') if p.strip()] path = '' last_category_node = self.root_item + tree_root = self.category_node_tree for i,p in enumerate(path_parts): path += p if path not in category_node_map: @@ -681,8 +708,12 @@ class TagsModel(QAbstractItemModel): # {{{ self.category_nodes.append(node) node.can_edit = (not is_gst) and (i == (len(path_parts)-1)) node.is_gst = is_gst + if not is_gst: + tree_root[p] = {} + tree_root = tree_root[p] else: last_category_node = category_node_map[path] + tree_root = tree_root[p] path += '.' else: node = TagTreeItem(parent=self.root_item, @@ -756,6 +787,7 @@ class TagsModel(QAbstractItemModel): # {{{ src is a list of tuples representing items to copy. The tuple is (type, containing category key, category key is global search term, full name, category key, id) + The 'id' member is ignored, and can be None. The type must be TagTreeItem.TAG dest is the TagTreeItem node to receive the items action is Qt.CopyAction or Qt.MoveAction @@ -1550,43 +1582,34 @@ class TagBrowserMixin(object): # {{{ self.tags_view.set_database(db, self.tag_match, self.sort_by) self.tags_view.tags_marked.connect(self.search.set_search_string) self.tags_view.tag_list_edit.connect(self.do_tags_list_edit) - self.tags_view.user_category_edit.connect(self.do_user_categories_edit) - self.tags_view.user_category_delete.connect(self.do_user_category_delete) - self.tags_view.del_user_cat_item.connect(self.do_del_user_cat_item) + self.tags_view.edit_user_category.connect(self.do_edit_user_categories) + self.tags_view.delete_user_category.connect(self.do_delete_user_category) + self.tags_view.del_item_from_user_cat.connect(self.do_del_item_from_user_cat) self.tags_view.add_subcategory.connect(self.do_add_subcategory) + self.tags_view.add_item_to_user_cat.connect(self.do_add_item_to_user_cat) self.tags_view.saved_search_edit.connect(self.do_saved_search_edit) self.tags_view.author_sort_edit.connect(self.do_author_sort_edit) self.tags_view.tag_item_renamed.connect(self.do_tag_item_renamed) self.tags_view.search_item_renamed.connect(self.saved_searches_changed) self.tags_view.drag_drop_finished.connect(self.drag_drop_finished) self.edit_categories.clicked.connect(lambda x: - self.do_user_categories_edit()) + self.do_edit_user_categories()) - def do_del_user_cat_item(self, user_cat, item_name, item_category): + def do_add_subcategory(self, on_category_key, new_category_name=None): ''' - Delete the item (item_name, item_category) from the user category with - key user_cat. Any leading '@' characters are removed + Add a subcategory to the category 'on_category'. If new_category_name is + None, then a default name is shown and the user is offered the + opportunity to edit the name. ''' - if user_cat.startswith('@'): - user_cat = user_cat[1:] - db = self.library_view.model().db - user_cats = db.prefs.get('user_categories', {}) - if user_cat not in user_cats: - error_dialog(self.tags_view, _('Remove category'), - _('User category %s does not exist')%user_cat, - show=True) - return - self.tags_view.model().delete_item_from_user_category(user_cat, - item_name, item_category) - self.tags_view.recount() - - def do_add_subcategory(self, on_category_key=None): db = self.library_view.model().db user_cats = db.prefs.get('user_categories', {}) # Ensure that the temporary name we will use is not already there i = 0 - new_name = _('New Category').replace('.', '') + if new_category_name is not None: + new_name = new_category_name.replace('.', '') + else: + new_name = _('New Category').replace('.', '') n = new_name while True: new_cat = on_category_key[1:] + '.' + n @@ -1602,9 +1625,13 @@ class TagBrowserMixin(object): # {{{ idx = m.index_for_path(m.find_category_node('@' + new_cat)) m.show_item_at_index(idx) # Open the editor on the new item to rename it - self.tags_view.edit(idx) + if new_category_name is None: + self.tags_view.edit(idx) - def do_user_categories_edit(self, on_category=None): + def do_edit_user_categories(self, on_category=None): + ''' + Open the user categories editor. + ''' db = self.library_view.model().db d = TagCategories(self, db, on_category) if d.exec_() == d.Accepted: @@ -1615,7 +1642,7 @@ class TagBrowserMixin(object): # {{{ db.data.change_search_locations(db.field_metadata.get_search_terms()) self.tags_view.set_new_model() - def do_user_category_delete(self, category_name): + def do_delete_user_category(self, category_name): ''' Delete the user category named category_name. Any leading '@' is removed ''' @@ -1648,7 +1675,55 @@ class TagBrowserMixin(object): # {{{ db.prefs.set('user_categories', user_cats) self.tags_view.set_new_model() + def do_del_item_from_user_cat(self, user_cat, item_name, item_category): + ''' + Delete the item (item_name, item_category) from the user category with + key user_cat. Any leading '@' characters are removed + ''' + if user_cat.startswith('@'): + user_cat = user_cat[1:] + db = self.library_view.model().db + user_cats = db.prefs.get('user_categories', {}) + if user_cat not in user_cats: + error_dialog(self.tags_view, _('Remove category'), + _('User category %s does not exist')%user_cat, + show=True) + return + self.tags_view.model().delete_item_from_user_category(user_cat, + item_name, item_category) + self.tags_view.recount() + + def do_add_item_to_user_cat(self, dest_category, src_name, src_category): + ''' + Add the item src_name in src_category to the user category + dest_category. Any leading '@' is removed + ''' + db = self.library_view.model().db + user_cats = db.prefs.get('user_categories', {}) + + if dest_category.startswith('@'): + dest_category = dest_category[1:] + if dest_category not in user_cats: + return error_dialog(self.tags_view, _('Add to user category'), + _('A user category %s does not exist')%dest_category, show=True) + + # Now add the item to the destination user category + add_it = True + if src_category == 'news': + src_category = 'tags' + for tup in user_cats[dest_category]: + if src_name == tup[0] and src_category == tup[1]: + add_it = False + if add_it: + user_cats[dest_category].append([src_name, src_category, 0]) + db.prefs.set('user_categories', user_cats) + self.tags_view.recount() + def do_tags_list_edit(self, tag, category): + ''' + Open the 'manage_X' dialog where X == category. If tag is not None, the + dialog will position the editor on that item. + ''' db=self.library_view.model().db if category == 'tags': result = db.get_tags_with_ids() @@ -1716,6 +1791,9 @@ class TagBrowserMixin(object): # {{{ # refreshing the tags view happens at the emit()/call() site def do_author_sort_edit(self, parent, id): + ''' + Open the manage authors dialog + ''' db = self.library_view.model().db editor = EditAuthorsDialog(parent, db, id) d = editor.exec_() From 68a798eba11b55477c79ef4d0a9a4bc70a44b19e Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 14:42:40 +0000 Subject: [PATCH 09/11] Remove '@' from send to user category context menu --- src/calibre/gui2/tag_view.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 804fb503d1..bb39ef3e29 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -313,14 +313,15 @@ class TagsView(QTreeView): # {{{ p = path[:] for k in sorted(tree_dict.keys(), key=sort_key): p.append(k) - m.addAction(self.user_category_icon, k, + n = k[1:] if k.startswith('@') else k + m.addAction(self.user_category_icon, n, partial(self.context_menu_handler, 'add_to_category', category='.'.join(p), index=tag)) if len(tree_dict[k]): tm = m.addMenu(self.user_category_icon, - _('Children of %s')%k) + _('Children of %s')%n) add_node_tree(tree_dict[k], tm, p) p.pop() add_node_tree(nt, m, []) From 7b7ac2f91eae0ec7a4c37a535f038982232a920b Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 14:49:44 +0000 Subject: [PATCH 10/11] Fix not focusing the manage X box on a hierarchical sub-item --- src/calibre/gui2/tag_view.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index bb39ef3e29..06f01a1649 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -386,7 +386,8 @@ class TagsView(QTreeView): # {{{ self.db.field_metadata[key]['is_custom']: self.context_menu.addAction(_('Manage %s')%category, partial(self.context_menu_handler, action='open_editor', - category=tag.name if tag else None, key=key)) + category=getattr(tag, 'original_name', tag.name) + if tag else None, key=key)) elif key == 'authors': self.context_menu.addAction(_('Manage %s')%category, partial(self.context_menu_handler, action='edit_author_sort')) From df46544ee8fde3718933cb59c19ce2634246ac29 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 15:02:19 +0000 Subject: [PATCH 11/11] Fix searching for prefixes where the prefix exists on a non-matching item --- src/calibre/library/caches.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index 4f5a034222..e626d446d2 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -128,7 +128,8 @@ def _match(query, value, matchkind): if query[0] == '.': if t.startswith(query[1:]): ql = len(query) - 1 - return (len(t) == ql) or (t[ql:ql+1] == '.') + if (len(t) == ql) or (t[ql:ql+1] == '.'): + return True elif query == t: return True elif ((matchkind == REGEXP_MATCH and re.search(query, t, re.I)) or ### search unanchored