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.tag_list_editor import TagListEditor 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 @@ class TagsView(QTreeView): # {{{ 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 @@ class TagsView(QTreeView): # {{{ 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 @@ class TagsView(QTreeView): # {{{ 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 @@ class TagsView(QTreeView): # {{{ 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 @@ class TagsView(QTreeView): # {{{ 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 @@ class TagsView(QTreeView): # {{{ _('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 @@ class TagsView(QTreeView): # {{{ 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 @@ 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=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 @@ class TagTreeItem(object): # {{{ 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 @@ class TagTreeItem(object): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagBrowserMixin(object): # {{{ 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 @@ class Tag(object): 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 @@ class TagTreeItem(object): # {{{ 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 @@ class TagTreeItem(object): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ 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. + 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ copyfile = os.link if hasattr(os, 'link') else shutil.copyfile 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 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): 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 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): 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 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): 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 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): 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 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): # 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 @@ class BrowseServer(object): 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 @@ class BrowseServer(object): 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 @@ class BrowseServer(object): 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 @@ class BrowseServer(object): - script = 'category(%s);'%script + if cats: + script = 'toplevel();category(%s);'%script + else: + script = 'category(%s);'%script main = u'''
    @@ -453,7 +508,7 @@ class BrowseServer(object): {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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class BrowseServer(object): 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 @@ class BrowseServer(object): 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 @@ class BrowseServer(object): 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 @@ class BrowseServer(object): 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 @@ class BrowseServer(object): 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 @@ Browsing book collection by tags. 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 @@ class TagTreeItem(object): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ # 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagCategories(QDialog, Ui_TagCategories): 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 @@ class TagCategories(QDialog, Ui_TagCategories): 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ # 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 @@ class TagsModel(QAbstractItemModel): # {{{ # 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 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): 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 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ class TagsModel(QAbstractItemModel): # {{{ 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]