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'
'
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')