From 84c9a3d15062eae4edc805c992c7dd0c7c2b91fd Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 21 Feb 2011 21:35:34 +0000 Subject: [PATCH] First pass at tag browser subfolders --- src/calibre/ebooks/metadata/book/__init__.py | 4 + src/calibre/ebooks/metadata/opf2.py | 8 +- src/calibre/gui2/tag_view.py | 138 ++++++++++++------- src/calibre/library/caches.py | 12 +- src/calibre/library/database2.py | 15 ++ src/calibre/library/field_metadata.py | 2 +- src/calibre/library/server/browse.py | 5 +- 7 files changed, 124 insertions(+), 60 deletions(-) diff --git a/src/calibre/ebooks/metadata/book/__init__.py b/src/calibre/ebooks/metadata/book/__init__.py index 82de7400d7..033a78d611 100644 --- a/src/calibre/ebooks/metadata/book/__init__.py +++ b/src/calibre/ebooks/metadata/book/__init__.py @@ -83,6 +83,10 @@ CALIBRE_METADATA_FIELDS = frozenset([ 'application_id', # An application id, currently set to the db_id. 'db_id', # the calibre primary key of the item. 'formats', # list of formats (extensions) for this book + # a dict of user category names, where the value is a list of item names + # from the book that are in that category + 'user_categories', + ] ) diff --git a/src/calibre/ebooks/metadata/opf2.py b/src/calibre/ebooks/metadata/opf2.py index dfb902b5b9..0d8e523c38 100644 --- a/src/calibre/ebooks/metadata/opf2.py +++ b/src/calibre/ebooks/metadata/opf2.py @@ -524,6 +524,8 @@ class OPF(object): # {{{ publication_type = MetadataField('publication_type', is_dc=False) timestamp = MetadataField('timestamp', is_dc=False, formatter=parse_date, renderer=isoformat) + user_categories = MetadataField('user_categories', is_dc=False, + formatter=json.loads, renderer=json.dumps) def __init__(self, stream, basedir=os.getcwdu(), unquote_urls=True, @@ -994,7 +996,7 @@ class OPF(object): # {{{ for attr in ('title', 'authors', 'author_sort', 'title_sort', 'publisher', 'series', 'series_index', 'rating', 'isbn', 'tags', 'category', 'comments', - 'pubdate'): + 'pubdate', 'user_categories'): val = getattr(mi, attr, None) if val is not None and val != [] and val != (None, None): setattr(self, attr, val) @@ -1175,6 +1177,8 @@ class OPFCreator(Metadata): a(CAL_ELEM('calibre:timestamp', self.timestamp.isoformat())) if self.publication_type is not None: a(CAL_ELEM('calibre:publication_type', self.publication_type)) + if self.user_categories is not None: + a(CAL_ELEM('calibre:user_categories', json.dumps(self.user_categories))) manifest = E.manifest() if self.manifest is not None: for ref in self.manifest: @@ -1299,6 +1303,8 @@ def metadata_to_opf(mi, as_string=True): meta('publication_type', mi.publication_type) if mi.title_sort: meta('title_sort', mi.title_sort) + if mi.user_categories: + meta('user_categories', json.dumps(mi.user_categories)) serialize_user_metadata(metadata, mi.get_all_user_metadata(False)) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 3af3271921..1660d9b8c6 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -263,8 +263,9 @@ class TagsView(QTreeView): # {{{ item = item.parent if item.type == TagTreeItem.CATEGORY: - while item.parent != self._model.root_item: - item = item.parent + if not item.category_key.startswith('@'): + while item.parent != self._model.root_item: + item = item.parent category = unicode(item.name.toString()) key = item.category_key # Verify that we are working with a field that we know something about @@ -552,8 +553,7 @@ class TagTreeItem(object): # {{{ res = [] for t in self.children: if t.type == TagTreeItem.CATEGORY: - for c in t.children: - res.append(c) + res.extend(t.child_tags()) else: res.append(t) return res @@ -590,6 +590,10 @@ class TagsModel(QAbstractItemModel): # {{{ data = self.get_node_tree(config['sort_tags_by']) gst = db.prefs.get('grouped_search_terms', {}) self.root_item = TagTreeItem() + self.category_nodes = [] + + last_category_node = None + category_node_map = {} for i, r in enumerate(self.row_map): if self.hidden_categories and self.categories[i] in self.hidden_categories: continue @@ -599,10 +603,31 @@ class TagsModel(QAbstractItemModel): # {{{ tt = '' else: tt = _(u'The lookup/search name is "{0}"').format(r) - TagTreeItem(parent=self.root_item, - data=self.categories[i], - category_icon=self.category_icon_map[r], - tooltip=tt, category_key=r) + if r.startswith('@') and r.find('/') >= 0: + path_parts = [p.strip() for p in r.split('/') if p.strip()] + path = '' + for i,p in enumerate(path_parts): + path += p + if path not in category_node_map: + node = TagTreeItem(parent=last_category_node, + data=p[1:] if i == 0 else p, + category_icon=self.category_icon_map[r], + tooltip=tt if path == r else path, + category_key=path) + last_category_node = node + category_node_map[path] = node + self.category_nodes.append(node) + else: + last_category_node = category_node_map[path] + path += '/' + else: + node = TagTreeItem(parent=self.root_item, + data=self.categories[i], + category_icon=self.category_icon_map[r], + tooltip=tt, category_key=r) + category_node_map[r] = node + last_category_node = node + self.category_nodes.append(node) self.refresh(data=data) def break_cycles(self): @@ -754,10 +779,15 @@ class TagsModel(QAbstractItemModel): # {{{ for user_cat in sorted(self.db.prefs.get('user_categories', {}).keys(), key=sort_key): cat_name = '@' + user_cat # add the '@' to avoid name collision - try: - tb_cats.add_user_category(label=cat_name, name=user_cat) - except ValueError: - traceback.print_exc() + while True: + try: + tb_cats.add_user_category(label=cat_name, name=user_cat) + slash = cat_name.rfind('/') + if slash < 0: + break + cat_name = cat_name[:slash] + except ValueError: + break for cat in sorted(self.db.prefs.get('grouped_search_terms', {}).keys(), key=sort_key): @@ -794,7 +824,7 @@ class TagsModel(QAbstractItemModel): # {{{ data = self.get_node_tree(sort_by) # get category data if data is None: return False - row_index = -1 + collapse = gprefs['tags_browser_collapse_at'] collapse_model = self.collapse_model if collapse == 0: @@ -810,35 +840,22 @@ class TagsModel(QAbstractItemModel): # {{{ collapse_template = tweaks['categories_collapsed_popularity_template'] collapse_letter = collapse_letter_sk = None - for i, r in enumerate(self.row_map): - if self.hidden_categories and self.categories[i] in self.hidden_categories: - continue - row_index += 1 - category = self.root_item.children[row_index] - names = [] - states = [] - children = category.child_tags() - states = [t.tag.state for t in children] - names = [t.tag.name for names in children] - state_map = dict(izip(names, states)) - category_index = self.index(row_index, 0, QModelIndex()) + def process_one_node(category, state_map, collapse_letter, collapse_letter_sk): + category_index = self.createIndex(category.row(), 0, category) category_node = category_index.internalPointer() - if len(category.children) > 0: - self.beginRemoveRows(category_index, 0, - len(category.children)-1) - category.children = [] - self.endRemoveRows() - cat_len = len(data[r]) + key = category_node.category_key + if key not in data: + return ((collapse_letter, collapse_letter_sk)) + cat_len = len(data[key]) if cat_len <= 0: - continue + return ((collapse_letter, collapse_letter_sk)) - self.beginInsertRows(category_index, 0, len(data[r])-1) - clear_rating = True if r not in self.categories_with_ratings and \ - not self.db.field_metadata[r]['is_custom'] and \ - not self.db.field_metadata[r]['kind'] == 'user' \ + clear_rating = True if key not in self.categories_with_ratings and \ + not self.db.field_metadata[key]['is_custom'] and \ + not self.db.field_metadata[key]['kind'] == 'user' \ else False - tt = r if self.db.field_metadata[r]['kind'] == 'user' else None - for idx,tag in enumerate(data[r]): + tt = key if self.db.field_metadata[key]['kind'] == 'user' else None + for idx,tag in enumerate(data[key]): if clear_rating: tag.avg_rating = None tag.state = state_map.get(tag.name, 0) @@ -848,15 +865,18 @@ class TagsModel(QAbstractItemModel): # {{{ if (idx % collapse) == 0: d = {'first': tag} if cat_len > idx + collapse: - d['last'] = data[r][idx+collapse-1] + d['last'] = data[key][idx+collapse-1] else: - d['last'] = data[r][cat_len-1] + d['last'] = data[key][cat_len-1] 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, category_icon = category_node.icon, category_key=category_node.category_key) + sub_cat_index = self.createIndex(sub_cat.row(), 0, sub_cat) + self.endInsertRows() else: ts = tag.sort if not ts: @@ -877,12 +897,34 @@ class TagsModel(QAbstractItemModel): # {{{ category_icon = category_node.icon, tooltip = None, category_key=category_node.category_key) - t = TagTreeItem(parent=sub_cat, data=tag, tooltip=tt, + sub_cat_index = self.createIndex(sub_cat.row(), 0, sub_cat) + self.beginInsertRows(sub_cat_index, 999999, 1) + TagTreeItem(parent=sub_cat, data=tag, tooltip=tt, icon_map=self.icon_state_map) else: - t = TagTreeItem(parent=category, data=tag, tooltip=tt, + self.beginInsertRows(category_index, 999999, 1) + TagTreeItem(parent=category, data=tag, tooltip=tt, icon_map=self.icon_state_map) - self.endInsertRows() + self.endInsertRows() + return ((collapse_letter, collapse_letter_sk)) + + for category in self.category_nodes: + if len(category.children) > 0: + children = category.children + states = [c.tag.state for c in children if c.type != TagTreeItem.CATEGORY] + names = [c.tag.name for c in children if c.type != TagTreeItem.CATEGORY] + state_map = dict(izip(names, states)) + ctags = [c for c in children if c.type == TagTreeItem.CATEGORY] + start = len(ctags) + self.beginRemoveRows(self.createIndex(category.row(), 0, category), + start, len(children)-1) + category.children = ctags + self.endRemoveRows() + else: + state_map = {} + + collapse_letter, collapse_letter_sk = process_one_node(category, + state_map, collapse_letter, collapse_letter_sk) return True def columnCount(self, parent): @@ -1073,14 +1115,10 @@ class TagsModel(QAbstractItemModel): # {{{ # They will be 'checked' in both places, but we want to put the node # into the search string only once. The nodes_seen set helps us do that nodes_seen = set() - row_index = -1 - for i, key in enumerate(self.row_map): - if self.hidden_categories and self.categories[i] in self.hidden_categories: - continue - row_index += 1 - category_item = self.root_item.children[row_index] - for tag_item in category_item.child_tags(): + for node in self.category_nodes: + key = node.category_key + for tag_item in node.child_tags(): tag = tag_item.tag if tag.state != TAG_SEARCH_STATES['clear']: prefix = ' not ' if tag.state == TAG_SEARCH_STATES['mark_minus'] \ diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index 45b96bb69f..da71ce0d4e 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -415,13 +415,13 @@ class ResultCache(SearchQueryParser): # {{{ if self.db_prefs is None: return res user_cats = self.db_prefs.get('user_categories', []) - if location not in user_cats: - return res c = set(candidates) - for (item, category, ign) in user_cats[location]: - s = self.get_matches(category, '=' + item, candidates=c) - c -= s - res |= s + for key in user_cats: + if key == location or key.startswith(location + '/'): + for (item, category, ign) in user_cats[key]: + s = self.get_matches(category, '=' + item, candidates=c) + c -= s + res |= s if query == 'false': return candidates - res return res diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 305d1581d7..385849ae79 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -812,6 +812,21 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): index_is_id=index_is_id), extra=self.get_custom_extra(idx, label=meta['label'], index_is_id=index_is_id)) + + user_cats = self.prefs['user_categories'] + user_cat_vals = {} + for ucat in user_cats: + res = [] + for name,cat,ign in user_cats[ucat]: + v = mi.get(cat, None) + if isinstance(v, list): + if name in v: + res.append([name,cat]) + elif name == v: + res.append([name,cat]) + user_cat_vals[ucat] = res + mi.user_categories = user_cat_vals + if get_cover: mi.cover = self.cover(id, index_is_id=True, as_path=True) return mi diff --git a/src/calibre/library/field_metadata.py b/src/calibre/library/field_metadata.py index 9b481a89d0..aff2803452 100644 --- a/src/calibre/library/field_metadata.py +++ b/src/calibre/library/field_metadata.py @@ -32,7 +32,7 @@ category_icon_map = { 'news' : 'news.png', 'tags' : 'tags.png', 'custom:' : 'column.png', - 'user:' : 'drawer.png', + 'user:' : 'tb_folder.png', 'search' : 'search.png' } diff --git a/src/calibre/library/server/browse.py b/src/calibre/library/server/browse.py index 5415cfe8bb..a4f55afbc7 100644 --- a/src/calibre/library/server/browse.py +++ b/src/calibre/library/server/browse.py @@ -168,7 +168,7 @@ def get_category_items(category, items, restriction, datatype, prefix): # {{{ q = i.category if not q: q = category - href = '/browse/matches/%s/%s'%(quote(q), quote(id_)) + href = '/browse/matches/%s/%s'%(quote(q.replace('/', '/')), quote(id_)) return templ.format(xml(name), rating, xml(desc), xml(href, True), rstring, prefix) @@ -367,7 +367,7 @@ class BrowseServer(object): u'{0}' u'{0}' u'') - .format(xml(x, True), xml(quote(y)), xml(_('Browse books by')), + .format(xml(x, True), xml(quote(y.replace('/', '/'))), xml(_('Browse books by')), self.opts.url_prefix, src='/browse/icon/'+z) for x, y, z in cats] @@ -387,6 +387,7 @@ class BrowseServer(object): return sort def browse_category(self, category, sort): + category = category.replace('/', '/') categories = self.categories_cache() if category not in categories: raise cherrypy.HTTPError(404, 'category not found')