From 9eda085a3be233da45f8a84691ebab22f4e1c90d Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 21:22:59 +0000 Subject: [PATCH 1/9] 1) refactor hierarchy code to build the category child map once. 2) make hierarchical items display hierarchically in user categories 3) make add item to category also copy child nodes 4) make remove node also remove child nodes 5) some basic code cleanups --- src/calibre/gui2/tag_view.py | 107 +++++++++++++++++++------------ src/calibre/library/database2.py | 2 + 2 files changed, 68 insertions(+), 41 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 06f01a1649..de82321124 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -32,6 +32,9 @@ from calibre.gui2.dialogs.edit_authors_dialog import EditAuthorsDialog from calibre.gui2.widgets import HistoryLineEdit +def original_name(t): + return getattr(t, 'original_name', t.name) + class TagDelegate(QItemDelegate): # {{{ def paint(self, painter, option, index): @@ -228,9 +231,13 @@ def context_menu_handler(self, action=None, category=None, 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) + tag = index.tag + if len(index.children) > 0: + for c in index.children: + self.add_item_to_user_cat.emit(category, original_name(c.tag), + c.tag.category) + self.add_item_to_user_cat.emit(category, original_name(tag), + tag.category) return if action == 'add_subcategory': self.add_subcategory.emit(key) @@ -242,8 +249,12 @@ def context_menu_handler(self, action=None, category=None, self.delete_user_category.emit(key) return if action == 'delete_item_from_user_category': - self.del_item_from_user_cat.emit(key, - getattr(index, 'original_name', index.name), index.category) + tag = index.tag + if len(index.children) > 0: + for c in index.children: + self.del_item_from_user_cat.emit(key, original_name(c.tag), + c.tag.category) + self.del_item_from_user_cat.emit(key, original_name(tag), tag.category) return if action == 'manage_searches': self.saved_search_edit.emit(category) @@ -278,8 +289,8 @@ def show_context_menu(self, point): tag = None if item.type == TagTreeItem.TAG: + tag_item = item tag = item.tag - can_edit = getattr(tag, 'can_edit', True) while item.type != TagTreeItem.CATEGORY: item = item.parent @@ -297,7 +308,7 @@ def show_context_menu(self, point): if tag: # If the user right-clicked on an editable item, then offer # the possibility of renaming that item. - if can_edit: + if tag.is_editable: # Add the 'rename' items self.context_menu.addAction(_('Rename %s')%tag.name, partial(self.context_menu_handler, action='edit_item', @@ -317,8 +328,7 @@ def add_node_tree(tree_dict, m, path): m.addAction(self.user_category_icon, n, partial(self.context_menu_handler, 'add_to_category', - category='.'.join(p), - index=tag)) + category='.'.join(p), index=tag_item)) if len(tree_dict[k]): tm = m.addMenu(self.user_category_icon, _('Children of %s')%n) @@ -331,7 +341,7 @@ def add_node_tree(tree_dict, m, path): _('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)) + key = key, index = tag_item)) # Add the search for value items self.context_menu.addAction(self.search_icon, _('Search for %s')%tag.name, @@ -345,7 +355,7 @@ def add_node_tree(tree_dict, m, path): index=index)) self.context_menu.addSeparator() elif key.startswith('@') and not item.is_gst: - if item.can_edit: + if item.can_be_edited: self.context_menu.addAction(self.user_category_icon, _('Rename %s')%item.py_name, partial(self.context_menu_handler, action='edit_item', @@ -386,8 +396,8 @@ def add_node_tree(tree_dict, m, path): self.db.field_metadata[key]['is_custom']: self.context_menu.addAction(_('Manage %s')%category, partial(self.context_menu_handler, action='open_editor', - category=getattr(tag, 'original_name', tag.name) - if tag else None, key=key)) + category=original_name(tag) 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')) @@ -597,8 +607,8 @@ def tag_data(self, role): p = self while p.parent.type != self.ROOT: p = p.parent - if p.category_key.startswith('@'): - name = getattr(tag, 'original_name', tag.name) + if not tag.is_hierarchical: + name = original_name(tag) else: name = tag.name tt_author = False @@ -608,7 +618,7 @@ def tag_data(self, role): else: return QVariant('[%d] %s'%(tag.count, name)) if role == Qt.EditRole: - return QVariant(getattr(tag, 'original_name', tag.name)) + return QVariant(original_name(tag)) if role == Qt.DecorationRole: return self.icon_state_map[tag.state] if role == Qt.ToolTipRole: @@ -708,7 +718,7 @@ def __init__(self, db, parent, hidden_categories=None, last_category_node = node category_node_map[path] = node self.category_nodes.append(node) - node.can_edit = (not is_gst) and (i == (len(path_parts)-1)) + node.can_be_edited = (not is_gst) and (i == (len(path_parts)-1)) node.is_gst = is_gst if not is_gst: tree_root[p] = {} @@ -748,8 +758,8 @@ def mimeData(self, indexes): p = node while p.type != TagTreeItem.CATEGORY: p = p.parent - d = (node.type, p.category_key, p.is_gst, - getattr(t, 'original_name', t.name), t.category, t.id) + d = (node.type, p.category_key, p.is_gst, original_name(t), + t.category, t.id) data.append(d) else: data.append(None) @@ -794,6 +804,7 @@ def move_or_copy_item_to_user_category(self, src, dest, action): dest is the TagTreeItem node to receive the items action is Qt.CopyAction or Qt.MoveAction ''' +##### TODO: must handle children of item being copied user_cats = self.db.prefs.get('user_categories', {}) parent_node = None copied_node = None @@ -1056,6 +1067,7 @@ def process_one_node(category, state_map, collapse_letter, collapse_letter_sk): if cat_len <= 0: return ((collapse_letter, collapse_letter_sk)) + category_child_map = {} fm = self.db.field_metadata[key] clear_rating = True if key not in self.categories_with_ratings and \ not fm['is_custom'] and \ @@ -1107,40 +1119,52 @@ def process_one_node(category, state_map, collapse_letter, collapse_letter_sk): else: node_parent = category - components = [t for t in tag.name.split('.')] - 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': + # category display order is important here. The following works + # only of all the non-user categories are displayed before the + # user categories + components = [t for t in original_name(tag).split('.')] + in_uc = fm['kind'] == 'user' + if (not tag.is_hierarchical) and (in_uc or + key in ['authors', 'publisher', 'news', 'formats', 'rating'] or + key not in self.db.prefs.get('categories_using_hierarchy', []) or + len(components) == 1): self.beginInsertRows(category_index, 999999, 1) - TagTreeItem(parent=node_parent, data=tag, tooltip=tt, + n = TagTreeItem(parent=node_parent, data=tag, tooltip=tt, icon_map=self.icon_state_map) + category_child_map[tag.name, tag.category] = n self.endInsertRows() - tag.can_edit = key != 'formats' and (key == 'news' or \ + tag.is_editable = key != 'formats' and (key == 'news' or \ 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 + if i == 0: + child_map = category_child_map + else: + child_map = dict([((t.tag.name, t.tag.category), t) + for t in node_parent.children if t.type != TagTreeItem.CATEGORY]) - if comp in child_map: - node_parent = child_map[comp] - node_parent.tag.count += tag.count - node_parent.tag.use_prefix = True + if (comp,tag.category) in child_map: + node_parent = child_map[(comp,tag.category)] + if not in_uc: + node_parent.tag.count += tag.count + node_parent.tag.is_hierarchical = True else: if i < len(components)-1: t = copy.copy(tag) t.original_name = '.'.join(components[:i+1]) - t.can_edit = False + t.is_editable = False else: t = tag - t.original_name = t.name - t.can_edit = True - t.use_prefix = True + if not in_uc: + t.original_name = t.name + t.is_editable = True + t.is_hierarchical = True t.name = comp self.beginInsertRows(category_index, 999999, 1) node_parent = TagTreeItem(parent=node_parent, data=t, tooltip=tt, icon_map=self.icon_state_map) + child_map[(comp,tag.category)] = node_parent self.endInsertRows() return ((collapse_letter, collapse_letter_sk)) @@ -1219,7 +1243,7 @@ def setData(self, index, value, role=Qt.EditRole): return True key = item.tag.category - name = getattr(item.tag, 'original_name', item.tag.name) + name = original_name(item.tag) # make certain we know about the item's category if key not in self.db.field_metadata: return False @@ -1306,7 +1330,7 @@ def flags(self, index, *args): if index.isValid(): node = self.data(index, Qt.UserRole) if node.type == TagTreeItem.TAG: - if getattr(node.tag, 'can_edit', True): + if node.tag.is_editable: ans |= Qt.ItemIsDragEnabled fm = self.db.metadata_for_field(node.tag.category) if node.tag.category in \ @@ -1438,8 +1462,8 @@ def tokens(self): if tag.name and tag.name[0] == u'\u2605': # char is a star. Assume rating ans.append('%s%s:%s'%(prefix, category, len(tag.name))) else: - name = getattr(tag, 'original_name', tag.name) - use_prefix = getattr(tag, 'use_prefix', False) + name = original_name(tag) + use_prefix = tag.is_hierarchical if category == 'tags': if name in tags_seen: continue @@ -1477,7 +1501,7 @@ def process_tag(depth, tag_index, tag_item, start_path): tag = tag_item.tag if tag is None: return False - name = getattr(tag, 'original_name', tag.name) + name = original_name(tag) if (equals_match and strcmp(name, txt) == 0) or \ (not equals_match and lower(name).find(txt) >= 0): self.path_found = path @@ -1703,8 +1727,9 @@ def do_add_item_to_user_cat(self, dest_category, src_name, src_category): db = self.library_view.model().db user_cats = db.prefs.get('user_categories', {}) - if dest_category.startswith('@'): + if dest_category and 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) diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index dce0b34aef..03fff3a5cd 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -51,6 +51,8 @@ def __init__(self, name, id=None, count=0, state=0, avg=0, sort=None, self.id = id self.count = count self.state = state + self.is_hierarchical = False + self.is_editable = True self.avg_rating = avg/2.0 if avg is not None else 0 self.sort = sort if self.avg_rating > 0: From 2b24a487faa3f8232c20f1d8e3593f6830adf5d6 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Fri, 25 Feb 2011 09:23:30 +0000 Subject: [PATCH 2/9] 1) Performance improvements when using hierarchical categories 2) ensure correct counts when using sub-categories 3) make drag & drop copy a node and its children 4) make tb searching work better with subcategories and hierarchies --- src/calibre/gui2/tag_view.py | 98 ++++++++++++++++++++++---------- src/calibre/library/database2.py | 10 +++- 2 files changed, 76 insertions(+), 32 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index de82321124..32171fd5f4 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -537,6 +537,7 @@ def __init__(self, data=None, category_icon=None, icon_map=None, parent=None, tooltip=None, category_key=None): self.parent = parent self.children = [] + self.id_set = set() self.boxed = False if self.parent is not None: self.parent.append(self) @@ -613,10 +614,12 @@ def tag_data(self, role): name = tag.name tt_author = False if role == Qt.DisplayRole: - if tag.count == 0: + count = len(self.id_set) + count = count if count > 0 else tag.count + if count == 0: return QVariant('%s'%(name)) else: - return QVariant('[%d] %s'%(tag.count, name)) + return QVariant('[%d] %s'%(count, name)) if role == Qt.EditRole: return QVariant(original_name(tag)) if role == Qt.DecorationRole: @@ -751,6 +754,7 @@ def mimeData(self, indexes): if idx.isValid(): # get some useful serializable data node = idx.internalPointer() + path = self.path_for_index(idx) if node.type == TagTreeItem.CATEGORY: d = (node.type, node.py_name, node.category_key) else: @@ -759,7 +763,7 @@ def mimeData(self, indexes): while p.type != TagTreeItem.CATEGORY: p = p.parent d = (node.type, p.category_key, p.is_gst, original_name(t), - t.category, t.id) + t.category, path) data.append(d) else: data.append(None) @@ -798,38 +802,30 @@ 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, category key is global search term, - full name, category key, id) - The 'id' member is ignored, and can be None. + full name, category key, path to node) The type must be TagTreeItem.TAG dest is the TagTreeItem node to receive the items action is Qt.CopyAction or Qt.MoveAction ''' -##### TODO: must handle children of item being copied - user_cats = self.db.prefs.get('user_categories', {}) - parent_node = None - copied_node = None - for s in src: - src_parent, src_parent_is_gst, src_name, src_cat = s[1:5] - parent_node = src_parent - if src_parent.startswith('@'): - is_uc = True - src_parent = src_parent[1:] - else: - is_uc = False - dest_key = dest.category_key[1:] - if dest_key not in user_cats: - continue - new_cat = [] + def process_source_node(user_cats, src_parent, src_parent_is_gst, + is_uc, dest_key, node): + ''' + Copy/move an item and all its children to the destination + ''' + copied = False + src_name = original_name(node.tag) + src_cat = node.tag.category # delete the item if the source is a user category and action is move if is_uc and not src_parent_is_gst and src_parent in user_cats and \ action == Qt.MoveAction: + new_cat = [] for tup in user_cats[src_parent]: if src_name == tup[0] and src_cat == tup[1]: continue new_cat.append(list(tup)) user_cats[src_parent] = new_cat else: - copied_node = (src_parent, src_name) + copied = True # Now add the item to the destination user category add_it = True @@ -841,19 +837,54 @@ def move_or_copy_item_to_user_category(self, src, dest, action): if add_it: user_cats[dest_key].append([src_name, src_cat, 0]) + for c in node.children: + copied = process_source_node(user_cats, src_parent, src_parent_is_gst, + is_uc, dest_key, c) + return copied + + user_cats = self.db.prefs.get('user_categories', {}) + parent_node = None + copied = False + path = None + for s in src: + src_parent, src_parent_is_gst = s[1:3] + path = s[5] + parent_node = src_parent + + if src_parent.startswith('@'): + is_uc = True + src_parent = src_parent[1:] + else: + is_uc = False + dest_key = dest.category_key[1:] + + if dest_key not in user_cats: + continue + + node = self.index_for_path(path) + if node: + copied = process_source_node(user_cats, src_parent, src_parent_is_gst, + is_uc, dest_key, node.internalPointer()) + self.db.prefs.set('user_categories', user_cats) self.tags_view.recount() + # Scroll to the item copied. If it was moved, scroll to the parent if parent_node is not None: + self.clear_boxed() m = self.tags_view.model() - 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) + if not copied: + p = path[-1] + if p == 0: + path = m.find_category_node(parent_node) + else: + path[-1] = p - 1 idx = m.index_for_path(path) self.tags_view.setExpanded(idx, True) - m.show_item_at_index(idx) + if idx.internalPointer().type == TagTreeItem.TAG: + m.show_item_at_index(idx, boxed=True) + else: + m.show_item_at_index(idx) return True def do_drop_from_library(self, md, action, row, column, parent): @@ -1131,6 +1162,8 @@ def process_one_node(category, state_map, collapse_letter, collapse_letter_sk): self.beginInsertRows(category_index, 999999, 1) n = TagTreeItem(parent=node_parent, data=tag, tooltip=tt, icon_map=self.icon_state_map) + if tag.id_set is not None: + n.id_set |= tag.id_set category_child_map[tag.name, tag.category] = n self.endInsertRows() tag.is_editable = key != 'formats' and (key == 'news' or \ @@ -1146,8 +1179,6 @@ def process_one_node(category, state_map, collapse_letter, collapse_letter_sk): if t.type != TagTreeItem.CATEGORY]) if (comp,tag.category) in child_map: node_parent = child_map[(comp,tag.category)] - if not in_uc: - node_parent.tag.count += tag.count node_parent.tag.is_hierarchical = True else: if i < len(components)-1: @@ -1166,6 +1197,8 @@ def process_one_node(category, state_map, collapse_letter, collapse_letter_sk): tooltip=tt, icon_map=self.icon_state_map) child_map[(comp,tag.category)] = node_parent self.endInsertRows() + # This id_set must not be None + node_parent.id_set |= tag.id_set return ((collapse_letter, collapse_letter_sk)) @@ -1583,11 +1616,16 @@ def process_tag(tag_index, tag_item): if tag_item.boxed: tag_item.boxed = False self.dataChanged.emit(tag_index, tag_index) + for i,c in enumerate(tag_item.children): + process_tag(self.index(i, 0, tag_index), c) def process_level(category_index): for j in xrange(self.rowCount(category_index)): tag_index = self.index(j, 0, category_index) tag_item = tag_index.internalPointer() + if tag_item.boxed: + tag_item.boxed = False + self.dataChanged.emit(tag_index, tag_index) if tag_item.type == TagTreeItem.CATEGORY: process_level(tag_index) else: diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 03fff3a5cd..a48e7a5d73 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -46,13 +46,14 @@ class Tag(object): def __init__(self, name, id=None, count=0, state=0, avg=0, sort=None, - tooltip=None, icon=None, category=None): + tooltip=None, icon=None, category=None, id_set=None): self.name = name self.id = id self.count = count self.state = state self.is_hierarchical = False self.is_editable = True + self.id_set = id_set self.avg_rating = avg/2.0 if avg is not None else 0 self.sort = sort if self.avg_rating > 0: @@ -1162,6 +1163,7 @@ def __init__(self, name, sort): self.n = name self.s = sort self.c = 0 + self.id_set = set() self.rt = 0 self.rc = 0 self.id = None @@ -1266,6 +1268,7 @@ def get_categories(self, sort='name', ids=None, icon_map=None): item = tag_class(val, sort_val) tcategories[cat][val] = item item.c += 1 + item.id_set.add(book[0]) item.id = item_id if rating > 0: item.rt += rating @@ -1283,6 +1286,7 @@ def get_categories(self, sort='name', ids=None, icon_map=None): item = tag_class(val, sort_val) tcategories[cat][val] = item item.c += 1 + item.id_set.add(book[0]) item.id = item_id if rating > 0: item.rt += rating @@ -1370,7 +1374,8 @@ def get_categories(self, sort='name', ids=None, icon_map=None): categories[category] = [tag_class(formatter(r.n), count=r.c, id=r.id, avg=avgr(r), sort=r.s, icon=icon, - tooltip=tooltip, category=category) + tooltip=tooltip, category=category, + id_set=r.id_set) for r in items] #print 'end phase "tags list":', time.clock() - last, 'seconds' @@ -1379,6 +1384,7 @@ def get_categories(self, sort='name', ids=None, icon_map=None): # Needed for legacy databases that have multiple ratings that # map to n stars for r in categories['rating']: + r.id_set = None for x in categories['rating']: if r.name == x.name and r.id != x.id: r.count = r.count + x.count From 976acd25a700501851bbdde01648522daca1abe0 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Fri, 25 Feb 2011 10:52:43 +0000 Subject: [PATCH 3/9] Attempt at content server with subcategories. --- src/calibre/library/server/browse.py | 65 +++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/src/calibre/library/server/browse.py b/src/calibre/library/server/browse.py index 5415cfe8bb..5963b918d3 100644 --- a/src/calibre/library/server/browse.py +++ b/src/calibre/library/server/browse.py @@ -342,6 +342,7 @@ def getter(x): return category_meta[x]['name'].lower() displayed_custom_fields = custom_fields_to_display(self.db) + uc_displayed = set() for category in sorted(categories, key=lambda x: sort_key(getter(x))): if len(categories[category]) == 0: continue @@ -361,7 +362,19 @@ def getter(x): icon = category_icon_map['user:'] else: icon = 'blank.png' - cats.append((meta['name'], category, icon)) + + if meta['kind'] == 'user': + dot = category.find('.') + if dot > 0: + cat = category[:dot] + if cat not in uc_displayed: + cats.append((meta['name'][:dot-1], cat, icon)) + uc_displayed.add(cat) + else: + cats.append((meta['name'], category, icon)) + uc_displayed.add(category) + else: + cats.append((meta['name'], category, icon)) cats = [(u'
  •  ' u'{0}' @@ -394,12 +407,51 @@ def browse_category(self, category, sort): category_name = category_meta[category]['name'] datatype = category_meta[category]['datatype'] + uc_displayed = set() + cats = [] + for ucat in sorted(categories.keys(), key=sort_key): + if len(categories[ucat]) == 0: + continue + if category == 'formats': + continue + meta = category_meta.get(ucat, None) + if meta is None: + continue + if meta['kind'] != 'user': + continue + cat_len = len(category) + if not (len(ucat) > cat_len and ucat.startswith(category+'.')): + continue + cat_len += 1 + icon = category_icon_map['user:'] + dot = ucat[cat_len:].find('.') + if dot > 0: + cat = ucat[cat_len:][:dot] + if cat not in uc_displayed: + cats.append((cat, ucat[:cat_len+dot], icon)) + uc_displayed.add(cat) + else: + cats.append((meta['name'], ucat, icon)) + uc_displayed.add(ucat) + + cats = u'\n\n'.join( + [(u'
  •  ' + u'{0}' + u'{0}' + u'
  • ') + .format(xml(x, True), xml(quote(y)), xml(_('Browse books by')), + self.opts.url_prefix, src='/browse/icon/'+z) + for x, y, z in cats]) + if cats: + cats = (u'\n
    \n' + '{0}
    ').format(cats) + script = 'toplevel();' + else: + script = 'true' items = categories[category] sort = self.browse_sort_categories(items, sort) - script = 'true' - if len(items) == 1: # Only one item in category, go directly to book list prefix = '' if self.is_wsgi else self.opts.url_prefix @@ -443,7 +495,10 @@ def browse_category(self, category, sort): - script = 'category(%s);'%script + if cats: + script = 'toplevel();category(%s);'%script + else: + script = 'category(%s);'%script main = u'''
    @@ -453,7 +508,7 @@ def browse_category(self, category, sort): {1}
    '''.format( - xml(_('Browsing by')+': ' + category_name), items, + xml(_('Browsing by')+': ' + category_name), cats + items, xml(_('Up'), True), self.opts.url_prefix) return self.browse_template(sort).format(title=category_name, From a1edb22898a1c7c4c4dd735f0f74c4ff58c34b8b Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Fri, 25 Feb 2011 11:13:20 +0000 Subject: [PATCH 4/9] Content server and tag browser categories fixes --- src/calibre/gui2/tag_view.py | 2 +- src/calibre/library/server/browse.py | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 32171fd5f4..0097de101f 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -882,7 +882,7 @@ def process_source_node(user_cats, src_parent, src_parent_is_gst, idx = m.index_for_path(path) self.tags_view.setExpanded(idx, True) if idx.internalPointer().type == TagTreeItem.TAG: - m.show_item_at_index(idx, boxed=True) + m.show_item_at_index(idx, box=True) else: m.show_item_at_index(idx) return True diff --git a/src/calibre/library/server/browse.py b/src/calibre/library/server/browse.py index 5963b918d3..4b595e999e 100644 --- a/src/calibre/library/server/browse.py +++ b/src/calibre/library/server/browse.py @@ -424,15 +424,16 @@ def browse_category(self, category, sort): continue cat_len += 1 icon = category_icon_map['user:'] - dot = ucat[cat_len:].find('.') + cat = ucat[cat_len:] + dot = cat.find('.') if dot > 0: - cat = ucat[cat_len:][:dot] + cat = cat[:dot] if cat not in uc_displayed: cats.append((cat, ucat[:cat_len+dot], icon)) uc_displayed.add(cat) else: - cats.append((meta['name'], ucat, icon)) - uc_displayed.add(ucat) + cats.append((cat, ucat, icon)) + uc_displayed.add(cat) cats = u'\n\n'.join( [(u'
  •  ' @@ -452,7 +453,7 @@ def browse_category(self, category, sort): items = categories[category] sort = self.browse_sort_categories(items, sort) - if len(items) == 1: + if not cats and len(items) == 1: # Only one item in category, go directly to book list prefix = '' if self.is_wsgi else self.opts.url_prefix html = get_category_items(category, items, From f907da143387a444e3cb3996b3a9fea6a56a0e7f Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Fri, 25 Feb 2011 11:21:59 +0000 Subject: [PATCH 5/9] ... --- src/calibre/library/server/browse.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/calibre/library/server/browse.py b/src/calibre/library/server/browse.py index 4b595e999e..7dfedcb6ff 100644 --- a/src/calibre/library/server/browse.py +++ b/src/calibre/library/server/browse.py @@ -407,6 +407,8 @@ def browse_category(self, category, sort): category_name = category_meta[category]['name'] datatype = category_meta[category]['datatype'] + # See if we have any sub-categories to display. As we find them, add + # them to the displayed set to avoid showing the same item twice uc_displayed = set() cats = [] for ucat in sorted(categories.keys(), key=sort_key): @@ -422,16 +424,19 @@ def browse_category(self, category, sort): cat_len = len(category) if not (len(ucat) > cat_len and ucat.startswith(category+'.')): continue - cat_len += 1 icon = category_icon_map['user:'] + # we have a subcategory. Find any further dots (further subcats) + cat_len += 1 cat = ucat[cat_len:] dot = cat.find('.') if dot > 0: + # More subcats cat = cat[:dot] if cat not in uc_displayed: cats.append((cat, ucat[:cat_len+dot], icon)) uc_displayed.add(cat) else: + # This is the end of the chain cats.append((cat, ucat, icon)) uc_displayed.add(cat) @@ -450,6 +455,7 @@ def browse_category(self, category, sort): else: script = 'true' + # Now do the category items items = categories[category] sort = self.browse_sort_categories(items, sort) From c703ade9fff66eccb98322ee46a03cc6083984fe Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Fri, 25 Feb 2011 13:23:32 +0000 Subject: [PATCH 6/9] New algorithm for first-letter partitioning --- src/calibre/gui2/tag_view.py | 82 ++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 27 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 0097de101f..4c4f74a6bb 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -9,7 +9,7 @@ import traceback, copy, cPickle -from itertools import izip +from itertools import izip, repeat from functools import partial from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, QFont, QSize, \ @@ -534,7 +534,7 @@ class TagTreeItem(object): # {{{ ROOT = 2 def __init__(self, data=None, category_icon=None, icon_map=None, - parent=None, tooltip=None, category_key=None): + parent=None, tooltip=None, category_key=None, temporary=False): self.parent = parent self.children = [] self.id_set = set() @@ -552,6 +552,7 @@ def __init__(self, data=None, category_icon=None, icon_map=None, self.bold_font.setBold(True) self.bold_font = QVariant(self.bold_font) self.category_key = category_key + self.temporary = temporary elif self.type == self.TAG: icon_map[0] = data.icon self.tag, self.icon_state_map = data, list(map(QVariant, icon_map)) @@ -1086,17 +1087,17 @@ def refresh(self, data=None): else: collapse_model = 'partition' collapse_template = tweaks['categories_collapsed_popularity_template'] - collapse_letter = collapse_letter_sk = None - def process_one_node(category, state_map, collapse_letter, collapse_letter_sk): + def process_one_node(category, state_map): + collapse_letter = None category_index = self.createIndex(category.row(), 0, category) category_node = category_index.internalPointer() key = category_node.category_key if key not in data: - return ((collapse_letter, collapse_letter_sk)) + return cat_len = len(data[key]) if cat_len <= 0: - return ((collapse_letter, collapse_letter_sk)) + return category_child_map = {} fm = self.db.field_metadata[key] @@ -1105,6 +1106,41 @@ def process_one_node(category, state_map, collapse_letter, collapse_letter_sk): not fm['kind'] == 'user' \ else False tt = key if fm['kind'] == 'user' else None + + if collapse_model == 'first letter': + # Build a list of 'equal' first letters by looking for + # overlapping ranges. If a range overlaps another, then the + # letters are assumed to be equivalent. ICU collating is complex + # beyond belief. This mechanism lets us determine the logical + # first character from ICU's standpoint. + chardict = {} + for idx,tag in enumerate(data[key]): + if not tag.sort: + c = ' ' + else: + c = icu_upper(tag.sort[0]) + if c not in chardict: + chardict[c] = [idx, idx] + else: + chardict[c][1] = idx + + # sort the ranges to facilitate detecting overlap + ranges = sorted([(v[0], v[1], c) for c,v in chardict.items()]) + + # Create a list of 'first letters' to use for each item in + # the category. The list is generated using the ranges. Overlaps + # are filled with the character that first occurs. + cl_list = list(repeat(None, len(data[key]))) + for t in ranges: + start = t[0] + c = t[2] + if cl_list[start] is None: + nc = c + else: + nc = cl_list[start] + for i in range(start, t[1]+1): + cl_list[i] = nc + for idx,tag in enumerate(data[key]): if clear_rating: tag.avg_rating = None @@ -1121,30 +1157,19 @@ def process_one_node(category, state_map, collapse_letter, collapse_letter_sk): name = eval_formatter.safe_format(collapse_template, d, 'TAG_VIEW', None) self.beginInsertRows(category_index, 999999, 1) #len(data[key])-1) - sub_cat = TagTreeItem(parent=category, - data = name, tooltip = None, + sub_cat = TagTreeItem(parent=category, data = name, + tooltip = None, temporary=True, category_icon = category_node.icon, category_key=category_node.category_key) self.endInsertRows() else: - ts = tag.sort - if not ts: - ts = ' ' - try: - sk = sort_key(ts)[0] - except: - sk = ts[0] - - if sk != collapse_letter_sk: - collapse_letter = upper(ts[0]) - try: - collapse_letter_sk = sort_key(collapse_letter)[0] - except: - collapse_letter_sk = collapse_letter + cl = cl_list[idx] + if cl != collapse_letter: + collapse_letter = cl sub_cat = TagTreeItem(parent=category, data = collapse_letter, category_icon = category_node.icon, - tooltip = None, + tooltip = None, temporary=True, category_key=category_node.category_key) node_parent = sub_cat else: @@ -1200,7 +1225,7 @@ def process_one_node(category, state_map, collapse_letter, collapse_letter_sk): # This id_set must not be None node_parent.id_set |= tag.id_set - return ((collapse_letter, collapse_letter_sk)) + return for category in self.category_nodes: if len(category.children) > 0: @@ -1208,7 +1233,11 @@ def process_one_node(category, state_map, collapse_letter, collapse_letter_sk): states = [c.tag.state for c in category.child_tags()] names = [(c.tag.name, c.tag.category) for c in category.child_tags()] state_map = dict(izip(names, states)) - ctags = [c for c in child_map if c.type == TagTreeItem.CATEGORY] + # temporary sub-categories (the partitioning ones) must follow + # the permanent sub-categories. This will happen naturally if + # the temp ones are added by process_node + ctags = [c for c in child_map if + c.type == TagTreeItem.CATEGORY and not c.temporary] start = len(ctags) self.beginRemoveRows(self.createIndex(category.row(), 0, category), start, len(child_map)-1) @@ -1217,8 +1246,7 @@ def process_one_node(category, state_map, collapse_letter, collapse_letter_sk): else: state_map = {} - collapse_letter, collapse_letter_sk = process_one_node(category, - state_map, collapse_letter, collapse_letter_sk) + process_one_node(category, state_map) return True def columnCount(self, parent): From 238a2b483971f6dc9f0c75ae250d138720281c9a Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Fri, 25 Feb 2011 13:32:43 +0000 Subject: [PATCH 7/9] ... --- src/calibre/gui2/tag_view.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 4c4f74a6bb..83ea97b880 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -1162,7 +1162,7 @@ def process_one_node(category, state_map): category_icon = category_node.icon, category_key=category_node.category_key) self.endInsertRows() - else: + else: # by 'first letter' cl = cl_list[idx] if cl != collapse_letter: collapse_letter = cl @@ -1224,7 +1224,6 @@ def process_one_node(category, state_map): self.endInsertRows() # This id_set must not be None node_parent.id_set |= tag.id_set - return for category in self.category_nodes: From 1228886b1c01994358617b0c53e8694e13cf4e05 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Fri, 25 Feb 2011 15:10:01 +0000 Subject: [PATCH 8/9] Fixes for user categories having names with leading or trailing dots or empty names. --- src/calibre/gui2/dialogs/tag_categories.py | 15 +++++++++++++++ src/calibre/gui2/tag_view.py | 13 +++++++++---- src/calibre/library/database2.py | 18 +++++++++++++++++- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/calibre/gui2/dialogs/tag_categories.py b/src/calibre/gui2/dialogs/tag_categories.py index af6632bb02..9bddb817cf 100644 --- a/src/calibre/gui2/dialogs/tag_categories.py +++ b/src/calibre/gui2/dialogs/tag_categories.py @@ -171,6 +171,13 @@ def add_category(self): cat_name = unicode(self.input_box.text()).strip() if cat_name == '': return False + comps = [c.strip() for c in cat_name.split('.') if c.strip()] + if len(comps) == 0 or '.'.join(comps) != cat_name: + error_dialog(self, _('Invalid name'), + _('That name contains leading or trailing periods, ' + 'multiple periods in a row or spaces before ' + 'or after periods.')).exec_() + return False for c in self.categories: if strcmp(c, cat_name) == 0: error_dialog(self, _('Name already used'), @@ -193,6 +200,14 @@ def rename_category(self): return False if not self.current_cat_name: return False + comps = [c.strip() for c in cat_name.split('.') if c.strip()] + if len(comps) == 0 or '.'.join(comps) != cat_name: + error_dialog(self, _('Invalid name'), + _('That name contains leading or trailing periods, ' + 'multiple periods in a row or spaces before ' + 'or after periods.')).exec_() + return False + for c in self.categories: if strcmp(c, cat_name) == 0: error_dialog(self, _('Name already used'), diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 83ea97b880..e4b4552504 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -685,7 +685,9 @@ def __init__(self, db, parent, hidden_categories=None, self.filter_categories_by = filter_categories_by self.collapse_model = collapse_model - # get_node_tree cannot return None here, because row_map is empty + # get_node_tree cannot return None here, because row_map is empty. Note + # that get_node_tree can indirectly change the user_categories dict. + data = self.get_node_tree(config['sort_tags_by']) gst = db.prefs.get('grouped_search_terms', {}) self.root_item = TagTreeItem() @@ -707,7 +709,7 @@ def __init__(self, db, parent, hidden_categories=None, tt = _(u'The lookup/search name is "{0}"').format(r) if r.startswith('@'): - path_parts = [p.strip() for p in r.split('.') if p.strip()] + path_parts = [p for p in r.split('.')] path = '' last_category_node = self.root_item tree_root = self.category_node_tree @@ -1178,7 +1180,10 @@ def process_one_node(category, state_map): # category display order is important here. The following works # only of all the non-user categories are displayed before the # user categories - components = [t for t in original_name(tag).split('.')] + components = [t.strip() for t in original_name(tag).split('.') + if t.strip()] + if len(components) == 0 or '.'.join(components) != original_name(tag): + components = [original_name(tag)] in_uc = fm['kind'] == 'user' if (not tag.is_hierarchical) and (in_uc or key in ['authors', 'publisher', 'news', 'formats', 'rating'] or @@ -1264,7 +1269,7 @@ def setData(self, index, value, role=Qt.EditRole): # working with the last item and that item is deleted, in which case # we position at the parent label path = index.model().path_for_index(index) - val = unicode(value.toString()) + val = unicode(value.toString()).strip() if not val: error_dialog(self.tags_view, _('Item is blank'), _('An item cannot be set to nothing. Delete it instead.')).exec_() diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 5155afe7e9..4be2ba4340 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -1181,6 +1181,22 @@ def __unicode__(self): return 'n=%s s=%s c=%d rt=%d rc=%d id=%s'%\ (self.n, self.s, self.c, self.rt, self.rc, self.id) + def clean_user_categories(self): + user_cats = self.prefs.get('user_categories', {}) + new_cats = {} + for k in user_cats: + comps = [c.strip() for c in k.split('.') if c.strip()] + if len(comps) == 0: + i = 1 + while True: + if unicode(i) not in user_cats: + new_cats[unicode(i)] = user_cats[k] + break + i += 1 + else: + new_cats['.'.join(comps)] = user_cats[k] + self.prefs.set('user_categories', new_cats) + return new_cats def get_categories(self, sort='name', ids=None, icon_map=None): #start = last = time.clock() @@ -1421,7 +1437,7 @@ def get_categories(self, sort='name', ids=None, icon_map=None): categories['formats'].sort(key = lambda x:x.name) #### Now do the user-defined categories. #### - user_categories = dict.copy(self.prefs['user_categories']) + user_categories = dict.copy(self.clean_user_categories()) # We want to use same node in the user category as in the source # category. To do that, we need to find the original Tag node. There is From 77307904ff707e666fe0ed70cf40b6a1700f0800 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Fri, 25 Feb 2011 15:30:37 +0000 Subject: [PATCH 9/9] more fixes to prevent invalid names --- src/calibre/gui2/tag_view.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index e4b4552504..8c7ecdb212 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -1276,6 +1276,12 @@ def setData(self, index, value, role=Qt.EditRole): return False item = index.internalPointer() if item.type == TagTreeItem.CATEGORY and item.category_key.startswith('@'): + if val.find('.') >= 0: + error_dialog(self.tags_view, _('Rename user category'), + _('You cannot use periods in the name when ' + 'renaming user categories'), show=True) + return False + user_cats = self.db.prefs.get('user_categories', {}) ckey = item.category_key[1:] dotpos = ckey.rfind('.') @@ -1288,7 +1294,7 @@ def setData(self, index, value, role=Qt.EditRole): if len(c) == len(ckey): if nkey in user_cats: error_dialog(self.tags_view, _('Rename user category'), - _('The name %s is already used'%nkey), show=True) + _('The name %s is already used')%nkey, show=True) return False user_cats[nkey] = user_cats[ckey] del user_cats[ckey]