From 84c9a3d15062eae4edc805c992c7dd0c7c2b91fd Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Mon, 21 Feb 2011 21:35:34 +0000 Subject: [PATCH 01/55] 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') From 40af13276eb2e4badb634d3473a4af7123686cae Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 22 Feb 2011 13:19:35 +0000 Subject: [PATCH 02/55] First pass at hierarchical tags --- src/calibre/gui2/tag_view.py | 109 ++++++++++++++++++++++------------ src/calibre/library/caches.py | 13 +++- 2 files changed, 80 insertions(+), 42 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 1660d9b8c6..8b353cd2b3 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -7,7 +7,7 @@ __docformat__ = 'restructuredtext en' Browsing book collection by tags. ''' -import traceback +import traceback, copy from itertools import izip from functools import partial @@ -551,11 +551,12 @@ class TagTreeItem(object): # {{{ def child_tags(self): res = [] - for t in self.children: - if t.type == TagTreeItem.CATEGORY: - res.extend(t.child_tags()) - else: - res.append(t) + def recurse(nodes, res): + for t in nodes: + if t.type != TagTreeItem.CATEGORY: + res.append(t) + recurse(t.children, res) + recurse(self.children, res) return res # }}} @@ -603,6 +604,7 @@ class TagsModel(QAbstractItemModel): # {{{ tt = '' else: tt = _(u'The lookup/search name is "{0}"').format(r) + if r.startswith('@') and r.find('/') >= 0: path_parts = [p.strip() for p in r.split('/') if p.strip()] path = '' @@ -858,7 +860,7 @@ class TagsModel(QAbstractItemModel): # {{{ for idx,tag in enumerate(data[key]): if clear_rating: tag.avg_rating = None - tag.state = state_map.get(tag.name, 0) + tag.state = state_map.get((tag.name, tag.category), 0) if collapse_model != 'disable' and cat_len > collapse: if collapse_model == 'partition': @@ -875,7 +877,6 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ -897,22 +898,45 @@ class TagsModel(QAbstractItemModel): # {{{ category_icon = category_node.icon, tooltip = None, category_key=category_node.category_key) - 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) + node_parent = sub_cat else: + node_parent = category + + components = [t for t in tag.name.split('.')] + if key in ['authors', 'publisher', 'title'] or len(components) == 1: self.beginInsertRows(category_index, 999999, 1) - TagTreeItem(parent=category, data=tag, tooltip=tt, + TagTreeItem(parent=node_parent, data=tag, tooltip=tt, icon_map=self.icon_state_map) - self.endInsertRows() + self.endInsertRows() + else: + print components + for i,comp in enumerate(components): + children = dict([(t.tag.name, t) for t in node_parent.children + if t.type != TagTreeItem.CATEGORY]) + if comp in children: + node_parent = children[comp] + else: + if i < len(components)-1: + t = copy.copy(tag) + t.original_name = '.'.join(components[:i+1]) + t.use_prefix = True + else: + t = tag + t.original_name = t.name + t.use_prefix = False + 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) + 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] + 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 children if c.type == TagTreeItem.CATEGORY] start = len(ctags) @@ -1064,27 +1088,31 @@ class TagsModel(QAbstractItemModel): # {{{ def reset_all_states(self, except_=None): update_list = [] - def process_tag(tag_index, tag_item): - tag = tag_item.tag - if tag is except_: - self.dataChanged.emit(tag_index, tag_index) - return - if tag.state != 0 or tag in update_list: - tag.state = 0 - update_list.append(tag) - self.dataChanged.emit(tag_index, tag_index) + def process_tag(tag_item): + if tag_item.type != TagTreeItem.CATEGORY: + tag = tag_item.tag + if tag is except_: + tag_index = self.createIndex(tag_item.row(), 0, tag_item) + self.dataChanged.emit(tag_index, tag_index) + elif tag.state != 0 or tag in update_list: + tag_index = self.createIndex(tag_item.row(), 0, tag_item) + tag.state = 0 + update_list.append(tag) + self.dataChanged.emit(tag_index, tag_index) + for t in tag_item.children: + process_tag(t) - 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.type == TagTreeItem.CATEGORY: - process_level(tag_index) - else: - process_tag(tag_index, tag_item) +# 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.type == TagTreeItem.CATEGORY: +# process_level(tag_index) +# else: +# process_tag(tag_index, tag_item) - for i in xrange(self.rowCount(QModelIndex())): - process_level(self.index(i, 0, QModelIndex())) + for t in self.root_item.children: + process_tag(t) def clear_state(self): self.reset_all_states() @@ -1127,15 +1155,18 @@ 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) if category == 'tags': - if tag.name in tags_seen: + if name in tags_seen: continue - tags_seen.add(tag.name) + tags_seen.add(name) if tag in nodes_seen: continue nodes_seen.add(tag) - ans.append('%s%s:"=%s"'%(prefix, category, - tag.name.replace(r'"', r'\"'))) + ans.append('%s%s:"=%s%s"'%(prefix, category, + '.' if use_prefix else '', + name.replace(r'"', r'\"'))) return ans def find_item_node(self, key, txt, start_path): diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index da71ce0d4e..a84a9e0940 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -124,9 +124,16 @@ def _match(query, value, matchkind): for t in value: t = icu_lower(t) try: ### ignore regexp exceptions, required because search-ahead tries before typing is finished - if ((matchkind == EQUALS_MATCH and query == t) or - (matchkind == REGEXP_MATCH and re.search(query, t, re.I)) or ### search unanchored - (matchkind == CONTAINS_MATCH and query in t)): + if (matchkind == EQUALS_MATCH): + if query[0] == '.': + if t.startswith(query[1:]): + ql = len(query) - 1 + print ql, t, query + return (len(t) == ql) or (t[ql:ql+1] == '.') + elif query == t: + return True + elif ((matchkind == REGEXP_MATCH and re.search(query, t, re.I)) or ### search unanchored + (matchkind == CONTAINS_MATCH and query in t)): return True except re.error: pass From 1543ff5ea6fd00b3019377504bd90797b67ff38e Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 22 Feb 2011 13:23:32 +0000 Subject: [PATCH 03/55] Change slash in folder to dots to avoid content server and template problems --- src/calibre/gui2/tag_view.py | 12 ++++++------ src/calibre/library/server/browse.py | 5 ++--- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 8b353cd2b3..d90365eceb 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -605,8 +605,8 @@ class TagsModel(QAbstractItemModel): # {{{ else: tt = _(u'The lookup/search name is "{0}"').format(r) - if r.startswith('@') and r.find('/') >= 0: - path_parts = [p.strip() for p in r.split('/') if p.strip()] + 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 @@ -621,7 +621,7 @@ class TagsModel(QAbstractItemModel): # {{{ self.category_nodes.append(node) else: last_category_node = category_node_map[path] - path += '/' + path += '.' else: node = TagTreeItem(parent=self.root_item, data=self.categories[i], @@ -784,10 +784,10 @@ class TagsModel(QAbstractItemModel): # {{{ while True: try: tb_cats.add_user_category(label=cat_name, name=user_cat) - slash = cat_name.rfind('/') - if slash < 0: + dot = cat_name.rfind('.') + if dot < 0: break - cat_name = cat_name[:slash] + cat_name = cat_name[:dot] except ValueError: break diff --git a/src/calibre/library/server/browse.py b/src/calibre/library/server/browse.py index a4f55afbc7..5415cfe8bb 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.replace('/', '/')), quote(id_)) + href = '/browse/matches/%s/%s'%(quote(q), 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.replace('/', '/'))), xml(_('Browse books by')), + .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] @@ -387,7 +387,6 @@ 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') From bfc53cd03172e442112069430e3ffbee6c75a602 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 22 Feb 2011 13:25:03 +0000 Subject: [PATCH 04/55] More slash to dot changes --- src/calibre/library/caches.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index a84a9e0940..c25ce1bcce 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -424,7 +424,7 @@ class ResultCache(SearchQueryParser): # {{{ user_cats = self.db_prefs.get('user_categories', []) c = set(candidates) for key in user_cats: - if key == location or key.startswith(location + '/'): + 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 From f946570332761cb562c4363070461fa8d80fcbbf Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 22 Feb 2011 14:22:42 +0000 Subject: [PATCH 05/55] 1) Make hierarchies display as complete tags in user categories. 2) Add a sublist template function for slicing names apart. --- src/calibre/gui2/tag_view.py | 25 ++++++++++++-------- src/calibre/utils/formatter_functions.py | 29 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index d90365eceb..b39c8b0fe3 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -516,7 +516,13 @@ class TagTreeItem(object): # {{{ name = tag.sort tt_author = True else: - name = tag.name + p = self + while p.parent.type != self.ROOT: + p = p.parent + if p.category_key.startswith('@'): + name = getattr(tag, 'original_name', tag.name) + else: + name = tag.name tt_author = False if role == Qt.DisplayRole: if tag.count == 0: @@ -903,18 +909,19 @@ class TagsModel(QAbstractItemModel): # {{{ node_parent = category components = [t for t in tag.name.split('.')] - if key in ['authors', 'publisher', 'title'] or len(components) == 1: + if key in ['authors', 'publisher', 'title'] or len(components) == 1 or \ + self.db.field_metadata[key]['kind'] == 'user': self.beginInsertRows(category_index, 999999, 1) TagTreeItem(parent=node_parent, data=tag, tooltip=tt, icon_map=self.icon_state_map) self.endInsertRows() else: - print components for i,comp in enumerate(components): - children = dict([(t.tag.name, t) for t in node_parent.children + child_map = dict([(t.tag.name, t) for t in node_parent.children if t.type != TagTreeItem.CATEGORY]) - if comp in children: - node_parent = children[comp] + if comp in child_map: + node_parent = child_map[comp] + node_parent.tag.count += tag.count else: if i < len(components)-1: t = copy.copy(tag) @@ -934,14 +941,14 @@ class TagsModel(QAbstractItemModel): # {{{ for category in self.category_nodes: if len(category.children) > 0: - children = category.children + child_map = category.children 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 children if c.type == TagTreeItem.CATEGORY] + ctags = [c for c in child_map if c.type == TagTreeItem.CATEGORY] start = len(ctags) self.beginRemoveRows(self.createIndex(category.row(), 0, category), - start, len(children)-1) + start, len(child_map)-1) category.children = ctags self.endRemoveRows() else: diff --git a/src/calibre/utils/formatter_functions.py b/src/calibre/utils/formatter_functions.py index 518f2ed140..03491c038a 100644 --- a/src/calibre/utils/formatter_functions.py +++ b/src/calibre/utils/formatter_functions.py @@ -396,6 +396,34 @@ class BuiltinListitem(BuiltinFormatterFunction): except: return '' +class BuiltinSublist(BuiltinFormatterFunction): + name = 'sublist' + arg_count = 4 + doc = _('sublist(val, start_index, end_index, separator) -- interpret the ' + ' value as a list of items separated by `separator`, returning a ' + ' new list made from the `start_index`th to the `end_index`th item. ' + 'The first item is number zero. If an index is negative, then it ' + 'counts from the end of the list. As a special case, an end_index ' + 'of zero is assumed to be the length of the list. Examples using ' + 'basic template mode and assuming a #genre value if A.B.C: ' + '{#genre:sublist(-1,0,.)} returns C
' + '{#genre:sublist(0,1,.)} returns A
' + '{#genre:sublist(0,-1,.)} returns A.B') + + def evaluate(self, formatter, kwargs, mi, locals, val, start_index, end_index, sep): + if not val: + return '' + si = int(start_index) + ei = int(end_index) + val = val.split(sep) + try: + if ei == 0: + return sep.join(val[si:]) + else: + return sep.join(val[si:ei]) + except: + return '' + class BuiltinUppercase(BuiltinFormatterFunction): name = 'uppercase' arg_count = 1 @@ -447,6 +475,7 @@ builtin_re = BuiltinRe() builtin_shorten = BuiltinShorten() builtin_strcat = BuiltinStrcat() builtin_strcmp = BuiltinStrcmp() +builtin_sublist = BuiltinSublist() builtin_substr = BuiltinSubstr() builtin_subtract = BuiltinSubtract() builtin_switch = BuiltinSwitch() From 68af4f7a1bd9537d26a5b67f981889c39d8bdc2c Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 22 Feb 2011 16:53:12 +0000 Subject: [PATCH 06/55] Use case-insensitive matching for user category items --- src/calibre/gui2/dialogs/tag_categories.py | 7 ++++--- src/calibre/library/database2.py | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/calibre/gui2/dialogs/tag_categories.py b/src/calibre/gui2/dialogs/tag_categories.py index 307baffb5b..b8a7e67c72 100644 --- a/src/calibre/gui2/dialogs/tag_categories.py +++ b/src/calibre/gui2/dialogs/tag_categories.py @@ -73,16 +73,17 @@ class TagCategories(QDialog, Ui_TagCategories): if idx == 0: continue for n in category_values[idx](): - t = Item(name=n, label=label, index=len(self.all_items),icon=category_icons[idx], exists=True) + t = Item(name=n, label=label, index=len(self.all_items), + icon=category_icons[idx], exists=True) self.all_items.append(t) - self.all_items_dict[label+':'+n] = t + self.all_items_dict[icu_lower(label+':'+n)] = t self.categories = dict.copy(db.prefs.get('user_categories', {})) if self.categories is None: self.categories = {} for cat in self.categories: for item,l in enumerate(self.categories[cat]): - key = ':'.join([l[1], l[0]]) + key = icu_lower(':'.join([l[1], l[0]])) t = self.all_items_dict.get(key, None) if l[1] in self.category_labels: if t is None: diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 09c755ab5e..5a0935c2c8 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -1421,7 +1421,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): # temporarily duplicating the categories lists. taglist = {} for c in categories.keys(): - taglist[c] = dict(map(lambda t:(t.name, t), categories[c])) + taglist[c] = dict(map(lambda t:(icu_lower(t.name), t), categories[c])) muc = self.prefs.get('grouped_search_make_user_categories', []) gst = self.prefs.get('grouped_search_terms', {}) @@ -1437,8 +1437,9 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): for user_cat in sorted(user_categories.keys(), key=sort_key): items = [] for (name,label,ign) in user_categories[user_cat]: - if label in taglist and name in taglist[label]: - items.append(taglist[label][name]) + n = icu_lower(name) + if label in taglist and n in taglist[label]: + items.append(taglist[label][n]) # else: do nothing, to not include nodes w zero counts cat_name = '@' + user_cat # add the '@' to avoid name collision # Not a problem if we accumulate entries in the icon map From 82e23f1bd72448a1d1bdba953a388ddd1702f7f3 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 22 Feb 2011 16:55:27 +0000 Subject: [PATCH 07/55] Change search to match a tag item X when a user category path @A.X is searched, in addition to the items in category A.X --- src/calibre/library/caches.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index c25ce1bcce..4f0e3f8d8b 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -423,12 +423,22 @@ class ResultCache(SearchQueryParser): # {{{ return res user_cats = self.db_prefs.get('user_categories', []) c = set(candidates) + l = location.rfind('.') + if l > 0: + alt_loc = location[0:l] + alt_item = location[l+1:] 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 + elif key == alt_loc: + for (item, category, ign) in user_cats[key]: + if item == alt_item: + s = self.get_matches(category, '=' + item, candidates=c) + c -= s + res |= s if query == 'false': return candidates - res return res From 14f9f53bcaa8251ef1bde9547f5b10d55fd61d8a Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 22 Feb 2011 17:45:37 +0000 Subject: [PATCH 08/55] Remove a print statement --- src/calibre/library/caches.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index 4f0e3f8d8b..318183eb10 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -128,7 +128,6 @@ def _match(query, value, matchkind): if query[0] == '.': if t.startswith(query[1:]): ql = len(query) - 1 - print ql, t, query return (len(t) == ql) or (t[ql:ql+1] == '.') elif query == t: return True From 166db2e4dea830c431d5d6660161949a602916dd Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 22 Feb 2011 18:12:33 +0000 Subject: [PATCH 09/55] Make sure editing the name of a node doesn't break anything. Prevent renaming of constructed nodes. It isn't quite right because inner-node renaming will create another hierarchy. Will deal with that at some point. --- src/calibre/gui2/tag_view.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index b39c8b0fe3..15ff986f2e 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -258,9 +258,13 @@ class TagsView(QTreeView): # {{{ if item.type == TagTreeItem.TAG: tag_item = item - tag_name = item.tag.name - tag_id = item.tag.id - item = item.parent + t = item.tag + tag_name = t.name + tag_id = t.id + can_edit = getattr(t, 'can_edit', True) + print can_edit, getattr(t, 'original_name', t.name), t.name + while item.type != TagTreeItem.CATEGORY: + item = item.parent if item.type == TagTreeItem.CATEGORY: if not item.category_key.startswith('@'): @@ -276,13 +280,14 @@ class TagsView(QTreeView): # {{{ if tag_name: # If the user right-clicked on an editable item, then offer # the possibility of renaming that item. - if key in ['authors', 'tags', 'series', 'publisher', 'search'] or \ + if can_edit and \ + key in ['authors', 'tags', 'series', 'publisher', 'search'] or \ (self.db.field_metadata[key]['is_custom'] and \ self.db.field_metadata[key]['datatype'] != 'rating'): # Add the 'rename' items self.context_menu.addAction(_('Rename %s')%tag_name, - partial(self.context_menu_handler, action='edit_item', - category=tag_item, index=index)) + partial(self.context_menu_handler, action='edit_item', + category=tag_item, index=index)) if key == 'authors': self.context_menu.addAction(_('Edit sort for %s')%tag_name, partial(self.context_menu_handler, @@ -530,7 +535,7 @@ class TagTreeItem(object): # {{{ else: return QVariant('[%d] %s'%(tag.count, name)) if role == Qt.EditRole: - return QVariant(tag.name) + return QVariant(getattr(tag, 'original_name', tag.name)) if role == Qt.DecorationRole: return self.icon_state_map[tag.state] if role == Qt.ToolTipRole: @@ -926,11 +931,12 @@ class TagsModel(QAbstractItemModel): # {{{ if i < len(components)-1: t = copy.copy(tag) t.original_name = '.'.join(components[:i+1]) - t.use_prefix = True + t.can_edit = False else: t = tag t.original_name = t.name - t.use_prefix = False + t.can_edit = True + t.use_prefix = True t.name = comp self.beginInsertRows(category_index, 999999, 1) node_parent = TagTreeItem(parent=node_parent, data=t, @@ -980,7 +986,10 @@ class TagsModel(QAbstractItemModel): # {{{ _('An item cannot be set to nothing. Delete it instead.')).exec_() return False item = index.internalPointer() - key = item.parent.category_key + itm = item.parent + while itm.type != TagTreeItem.CATEGORY: + itm = itm.parent + key = itm.category_key # make certain we know about the item's category if key not in self.db.field_metadata: return False From 4cde033d6600da8f44fe900c5a1b1266a2bceb07 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 22 Feb 2011 18:59:39 +0000 Subject: [PATCH 10/55] Add to preferences/look & feel a list of fields for which items are hierarchical. --- src/calibre/gui2/preferences/look_feel.py | 13 +++++++++- src/calibre/gui2/preferences/look_feel.ui | 29 +++++++++++++++++++++++ src/calibre/gui2/tag_view.py | 3 ++- src/calibre/library/database2.py | 1 + 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/calibre/gui2/preferences/look_feel.py b/src/calibre/gui2/preferences/look_feel.py index 196ef16b08..f7d76f2b70 100644 --- a/src/calibre/gui2/preferences/look_feel.py +++ b/src/calibre/gui2/preferences/look_feel.py @@ -7,17 +7,19 @@ __docformat__ = 'restructuredtext en' from PyQt4.Qt import QApplication, QFont, QFontInfo, QFontDialog -from calibre.gui2.preferences import ConfigWidgetBase, test_widget +from calibre.gui2.preferences import ConfigWidgetBase, test_widget, CommaSeparatedList from calibre.gui2.preferences.look_feel_ui import Ui_Form from calibre.gui2 import config, gprefs, qt_app from calibre.utils.localization import available_translations, \ get_language, get_lang from calibre.utils.config import prefs +from calibre.utils.icu import sort_key class ConfigWidget(ConfigWidgetBase, Ui_Form): def genesis(self, gui): self.gui = gui + db = gui.library_view.model().db r = self.register @@ -61,6 +63,15 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): r('tags_browser_partition_method', gprefs, choices=choices) r('tags_browser_collapse_at', gprefs) + choices = set([k for k in db.field_metadata.all_field_keys() + if db.field_metadata[k]['is_category'] and + db.field_metadata[k]['datatype'] in ['text', 'series', 'enumeration']]) + choices -= set(['authors', 'publisher', 'formats', 'news']) + self.opt_categories_using_hierarchy.update_items_cache(choices) + r('categories_using_hierarchy', db.prefs, setting=CommaSeparatedList, + choices=sorted(list(choices), key=sort_key)) + + self.current_font = None self.change_font_button.clicked.connect(self.change_font) diff --git a/src/calibre/gui2/preferences/look_feel.ui b/src/calibre/gui2/preferences/look_feel.ui index 3f2bb3e145..a1fc5bb490 100644 --- a/src/calibre/gui2/preferences/look_feel.ui +++ b/src/calibre/gui2/preferences/look_feel.ui @@ -190,6 +190,28 @@ up into sub-categories. If the partition method is set to disable, this value is + + + + Categories with hierarchical items: + + + opt_categories_using_hierarchy + + + + + + + A comma-separated list of columns in which items containing +periods are displayed in the tag browser trees. For example, if +this box contains 'tags' then tags of the form 'mystery.English' +and 'mystery.Thriller' will be displayed with English and Thriller +both under the label 'mystery'. If 'tags' is not in this box, +then the tags will be displayed each on their own line. + + + @@ -275,6 +297,13 @@ up into sub-categories. If the partition method is set to disable, this value is + + + MultiCompleteLineEdit + QLineEdit +
calibre/gui2/complete.h
+
+
diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 15ff986f2e..04b336d791 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -914,7 +914,8 @@ class TagsModel(QAbstractItemModel): # {{{ node_parent = category components = [t for t in tag.name.split('.')] - if key in ['authors', 'publisher', 'title'] or len(components) == 1 or \ + if key not in self.db.prefs.get('categories_using_hierarchy', []) \ + or len(components) == 1 or \ self.db.field_metadata[key]['kind'] == 'user': self.beginInsertRows(category_index, 999999, 1) TagTreeItem(parent=node_parent, data=tag, tooltip=tt, diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 5a0935c2c8..e515abd709 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -174,6 +174,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): self.prefs = DBPrefs(self) defs = self.prefs.defaults defs['gui_restriction'] = defs['cs_restriction'] = '' + defs['categories_using_hierarchy'] = '' # Migrate saved search and user categories to db preference scheme def migrate_preference(key, default): From a256a21f7eaf8e9338c7a278e3729c9768aa993e Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Tue, 22 Feb 2011 21:09:45 +0000 Subject: [PATCH 11/55] More robust serialization for user_categories in OPFs --- src/calibre/ebooks/metadata/opf2.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/calibre/ebooks/metadata/opf2.py b/src/calibre/ebooks/metadata/opf2.py index 0d8e523c38..d5263a5052 100644 --- a/src/calibre/ebooks/metadata/opf2.py +++ b/src/calibre/ebooks/metadata/opf2.py @@ -470,6 +470,10 @@ def serialize_user_metadata(metadata_elem, all_user_metadata, tail='\n'+(' '*8)) metadata_elem.append(meta) +def dump_user_categories(cats): + from calibre.ebooks.metadata.book.json_codec import object_to_unicode + return json.dumps(object_to_unicode(cats)) + class OPF(object): # {{{ MIMETYPE = 'application/oebps-package+xml' @@ -525,7 +529,8 @@ class OPF(object): # {{{ 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) + formatter=json.loads, + renderer=dump_user_categories) def __init__(self, stream, basedir=os.getcwdu(), unquote_urls=True, @@ -1178,7 +1183,9 @@ class OPFCreator(Metadata): 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))) + from calibre.ebooks.metadata.book.json_codec import object_to_unicode + a(CAL_ELEM('calibre:user_categories', + json.dumps(object_to_unicode(self.user_categories)))) manifest = E.manifest() if self.manifest is not None: for ref in self.manifest: From 6460f08b7fbb2365ecd1542bedb6ec55687297c0 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 22 Feb 2011 20:17:57 -0700 Subject: [PATCH 12/55] Only start emailer thread on demand --- src/calibre/gui2/email.py | 5 ++++- src/calibre/gui2/ui.py | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/calibre/gui2/email.py b/src/calibre/gui2/email.py index 426747e044..c84b3180f7 100644 --- a/src/calibre/gui2/email.py +++ b/src/calibre/gui2/email.py @@ -209,7 +209,6 @@ class EmailMixin(object): # {{{ def __init__(self): self.emailer = Emailer(self.job_manager) - self.emailer.start() def send_by_mail(self, to, fmts, delete_from_library, send_ids=None, do_auto_convert=True, specific_format=None): @@ -255,6 +254,8 @@ class EmailMixin(object): # {{{ to_s = list(repeat(to, len(attachments))) if attachments: + if not self.emailer.is_alive(): + self.emailer.start() self.emailer.send_mails(jobnames, Dispatcher(partial(self.email_sent, remove=remove)), attachments, to_s, subjects, texts, attachment_names) @@ -325,6 +326,8 @@ class EmailMixin(object): # {{{ files, auto = self.library_view.model().\ get_preferred_formats_from_ids([id_], fmts) return files + if not self.emailer.is_alive(): + self.emailer.start() sent_mails = self.emailer.email_news(mi, remove, get_fmts, self.email_sent) if sent_mails: diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index 9b9308d253..8844446de6 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -633,7 +633,8 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ mb.stop() self.hide_windows() - self.emailer.stop() + if self.emailer.is_alive(): + self.emailer.stop() try: try: if self.content_server is not None: From 52c19d7b9bdd15bf74e84744f61a5b22c151fdb0 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 22 Feb 2011 20:34:06 -0700 Subject: [PATCH 13/55] caching of google identifiers and logic to get cover url from google identifier --- src/calibre/ebooks/metadata/sources/base.py | 16 ++++- src/calibre/ebooks/metadata/sources/google.py | 66 +++++++++++++------ 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/src/calibre/ebooks/metadata/sources/base.py b/src/calibre/ebooks/metadata/sources/base.py index 74e184cc66..54d7d49d6d 100644 --- a/src/calibre/ebooks/metadata/sources/base.py +++ b/src/calibre/ebooks/metadata/sources/base.py @@ -7,7 +7,7 @@ __license__ = 'GPL v3' __copyright__ = '2011, Kovid Goyal ' __docformat__ = 'restructuredtext en' -import re +import re, threading from calibre.customize import Plugin from calibre.utils.logging import ThreadSafeLog, FileStream @@ -30,7 +30,21 @@ class Source(Plugin): touched_fields = frozenset() + def __init__(self, *args, **kwargs): + Plugin.__init__(self, *args, **kwargs) + self._isbn_to_identifier_cache = {} + self.cache_lock = threading.RLock() + # Utility functions {{{ + + def cache_isbn_to_identifier(self, isbn, identifier): + with self.cache_lock: + self._isbn_to_identifier_cache[isbn] = identifier + + def cached_isbn_to_identifier(self, isbn): + with self.cache_lock: + return self._isbn_to_identifier_cache.get(isbn, None) + def get_author_tokens(self, authors, only_first_author=True): ''' Take a list of authors and return a list of tokens useful for an diff --git a/src/calibre/ebooks/metadata/sources/google.py b/src/calibre/ebooks/metadata/sources/google.py index 498c7574ea..0720b21ded 100644 --- a/src/calibre/ebooks/metadata/sources/google.py +++ b/src/calibre/ebooks/metadata/sources/google.py @@ -13,6 +13,7 @@ from functools import partial from lxml import etree +from calibre.ebooks.metadata import check_isbn from calibre.ebooks.metadata.sources.base import Source from calibre.ebooks.metadata.book.base import Metadata from calibre.ebooks.chardet import xml_to_unicode @@ -69,6 +70,7 @@ def to_metadata(browser, log, entry_, timeout): id_url = entry_id(entry_)[0].text + google_id = id_url.split('/')[-1] title_ = ': '.join([x.text for x in title(entry_)]).strip() authors = [x.text.strip() for x in creator(entry_) if x.text] if not authors: @@ -78,6 +80,7 @@ def to_metadata(browser, log, entry_, timeout): return None mi = Metadata(title_, authors) + mi.identifiers = {'google':google_id} try: raw = get_details(browser, id_url, timeout) feed = etree.fromstring(xml_to_unicode(clean_ascii_chars(raw), @@ -103,9 +106,12 @@ def to_metadata(browser, log, entry_, timeout): t = str(x.text).strip() if t[:5].upper() in ('ISBN:', 'LCCN:', 'OCLC:'): if t[:5].upper() == 'ISBN:': - isbns.append(t[5:]) + t = check_isbn(t[5:]) + if t: + isbns.append(t) if isbns: mi.isbn = sorted(isbns, key=len)[-1] + mi.all_isbns = isbns # Tags try: @@ -133,20 +139,6 @@ def to_metadata(browser, log, entry_, timeout): return mi -def get_all_details(br, log, entries, abort, result_queue, timeout): - for i in entries: - try: - ans = to_metadata(br, log, i, timeout) - if isinstance(ans, Metadata): - result_queue.put(ans) - except: - log.exception( - 'Failed to get metadata for identify entry:', - etree.tostring(i)) - if abort.is_set(): - break - - class GoogleBooks(Source): name = 'Google Books' @@ -185,6 +177,36 @@ class GoogleBooks(Source): 'min-viewability':'none', }) + def cover_url_from_identifiers(self, identifiers): + goog = identifiers.get('google', None) + if goog is None: + isbn = identifiers.get('isbn', None) + goog = self.cached_isbn_to_identifier(isbn) + if goog is not None: + return ('http://books.google.com/books?id=%s&printsec=frontcover&img=1' % + goog) + + def is_cover_image_valid(self, raw): + # When no cover is present, returns a PNG saying image not available + # Try for example google identifier llNqPwAACAAJ + # I have yet to see an actual cover in PNG format + return raw and len(raw) > 17000 and raw[1:4] != 'PNG' + + def get_all_details(self, br, log, entries, abort, result_queue, timeout): + for i in entries: + try: + ans = to_metadata(br, log, i, timeout) + if isinstance(ans, Metadata): + result_queue.put(ans) + for isbn in ans.all_isbns: + self.cache_isbn_to_identifier(isbn, + ans.identifiers['google']) + except: + log.exception( + 'Failed to get metadata for identify entry:', + etree.tostring(i)) + if abort.is_set(): + break def identify(self, log, result_queue, abort, title=None, authors=None, identifiers={}, timeout=5): @@ -207,8 +229,8 @@ class GoogleBooks(Source): return as_unicode(e) # There is no point running these queries in threads as google - # throttles requests returning Forbidden errors - get_all_details(br, log, entries, abort, result_queue, timeout) + # throttles requests returning 403 Forbidden errors + self.get_all_details(br, log, entries, abort, result_queue, timeout) return None @@ -218,8 +240,14 @@ if __name__ == '__main__': title_test) test_identify_plugin(GoogleBooks.name, [ + ( - {'title': 'Great Expectations', 'authors':['Charles Dickens']}, - [title_test('Great Expectations', exact=True)] + {'identifiers':{'isbn': '0743273567'}}, + [title_test('The great gatsby', exact=True)] ), + + #( + # {'title': 'Great Expectations', 'authors':['Charles Dickens']}, + # [title_test('Great Expectations', exact=True)] + #), ]) From 09d0c75755f07d063b20783c1ae54ed8b616db05 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 22 Feb 2011 22:38:00 -0700 Subject: [PATCH 14/55] ... --- src/calibre/gui2/dialogs/drm_error.ui | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/calibre/gui2/dialogs/drm_error.ui b/src/calibre/gui2/dialogs/drm_error.ui index ff28ef5a48..c4b9a1cfdb 100644 --- a/src/calibre/gui2/dialogs/drm_error.ui +++ b/src/calibre/gui2/dialogs/drm_error.ui @@ -44,7 +44,8 @@ <p>This book is locked by <b>DRM</b>. To learn more about DRM and why you cannot read or convert this book in calibre, -<a href="http://bugs.calibre-ebook.com/wiki/DRM">click here</a>. + <a href="http://drmfree.calibre-ebook.com/about#drm">click here</a>.<p>A large number of recent, DRM free releases are + available at <a href="http://drmfree.calibre-ebook.com">Open Books</a>. true From 6133b873e14377e01af2955abfbb45f054c68eb8 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 23 Feb 2011 06:32:24 -0700 Subject: [PATCH 15/55] 20 Minutos by Darko Miletic. Fixes #9115 (New recipe for 20 Minutos spanish diary) --- resources/images/news/20minutos.png | Bin 0 -> 800 bytes resources/recipes/20minutos.recipe | 68 ++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 resources/images/news/20minutos.png create mode 100644 resources/recipes/20minutos.recipe diff --git a/resources/images/news/20minutos.png b/resources/images/news/20minutos.png new file mode 100644 index 0000000000000000000000000000000000000000..3e656913c7390b9a7460c0d5b914f83b240fa6aa GIT binary patch literal 800 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM3?#3wJbQ|Pfsr-9C&ZP(W(tEvk3`@c4#z2$ zscXGTw+7X2<#3%N;5)}MbFF6N(sP%e|NsAg>%Mag1}zLGJ) ze}4a7cI}k4`>r_^Ze6Eodl*cnFzECc#xA{g|2c_+xMJu z*S0J=7nRwv_VB&`pWhpXwk+LtZOW>13>GZ{jy+RmEoIQDwoIJjlsYB3Yp z95y|gzAaI;TbC|dyLQ{Lwd=N4w=F$(`FZvvAbYQ(XUpEh=M;UX1QpH+%AI2v+GClz zbkD;3nZR&iO!9Vj;c46)m<;4_7I;J!18EO1b~~AE2UHN`>EaloaenH=$lyZ;BCYzq z;#}cOLJNbY1Wj8eA{ZbjE@EDAnD@*7|00`v`u4p%dHQ5c@uX^@hW#2}i<1Q|Puk?$ zzN9moJN>3)5`$u7NYrUY(@(d=H5$K82~FvF=(aMaVzy{}Mc7@fZk7vgqjWi5Me<1B z$YFK7!1qZ8 zeUv+OWS`d!Eyt6M>eH-~zH(WgIa!fkQ^M0S@5N33W>2eAFU1uuN=k$YKYjD2<`nN_ z?E{^!4+uz$CrZR^+bv*zVVTf`jrW_hzpURW;!x9k^3Cre#%I46Sg(W})@Rt?n5XBc zGdttL)VX=4E8M0%kCf#4$nidEo_f?t_mcPCu91sh6lK}ktn%t!{#;yPbL*?d^}-AC zE8lVlEA891IfhgBey8&s{-@!^AGiL$rfxD<_nMqgYa%e1RZCnWN>UO_QmvAUQh^kM zk%6J1u7Q!Rk#UHDnU%4Dl@X9@Ze?IlbgH}xMMG|WN@iLmiUvz7Lt`sbBZ!7I6S=nn PH86O(`njxgN@xNAIwe#H literal 0 HcmV?d00001 diff --git a/resources/recipes/20minutos.recipe b/resources/recipes/20minutos.recipe new file mode 100644 index 0000000000..d7657f77c7 --- /dev/null +++ b/resources/recipes/20minutos.recipe @@ -0,0 +1,68 @@ +__license__ = 'GPL v3' +__copyright__ = '2011, Darko Miletic ' +''' +www.20minutos.es +''' + +from calibre.web.feeds.news import BasicNewsRecipe + +class t20Minutos(BasicNewsRecipe): + title = '20 Minutos' + __author__ = 'Darko Miletic' + description = 'Diario de informacion general y local mas leido de Espania, noticias de ultima hora de Espania, el mundo, local, deportes, noticias curiosas y mas' + publisher = '20 Minutos Online SL' + category = 'news, politics, Spain' + oldest_article = 2 + max_articles_per_feed = 200 + no_stylesheets = True + encoding = 'utf8' + use_embedded_content = True + language = 'es' + remove_empty_feeds = True + publication_type = 'newspaper' + masthead_url = 'http://estaticos.20minutos.es/css4/img/ui/logo-301x54.png' + extra_css = """ + body{font-family: Arial,Helvetica,sans-serif } + img{margin-bottom: 0.4em; display:block} + """ + + conversion_options = { + 'comment' : description + , 'tags' : category + , 'publisher' : publisher + , 'language' : language + } + + remove_tags = [dict(attrs={'class':'mf-viral'})] + remove_attributes=['border'] + + feeds = [ + (u'Principal' , u'http://20minutos.feedsportal.com/c/32489/f/478284/index.rss') + ,(u'Cine' , u'http://20minutos.feedsportal.com/c/32489/f/478285/index.rss') + ,(u'Internacional' , u'http://20minutos.feedsportal.com/c/32489/f/492689/index.rss') + ,(u'Deportes' , u'http://20minutos.feedsportal.com/c/32489/f/478286/index.rss') + ,(u'Nacional' , u'http://20minutos.feedsportal.com/c/32489/f/492688/index.rss') + ,(u'Economia' , u'http://20minutos.feedsportal.com/c/32489/f/492690/index.rss') + ,(u'Tecnologia' , u'http://20minutos.feedsportal.com/c/32489/f/478292/index.rss') + ] + + def preprocess_html(self, soup): + for item in soup.findAll(style=True): + del item['style'] + for item in soup.findAll('a'): + limg = item.find('img') + if item.string is not None: + str = item.string + item.replaceWith(str) + else: + if limg: + item.name = 'div' + item.attrs = [] + else: + str = self.tag_to_string(item) + item.replaceWith(str) + for item in soup.findAll('img'): + if not item.has_key('alt'): + item['alt'] = 'image' + return soup + From 7364e266277ce81958bb205b6f85d092420d5aba Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 23 Feb 2011 07:03:02 -0700 Subject: [PATCH 16/55] Some documentation for the Tag Browser --- src/calibre/manual/gui.rst | 12 ++++++++++++ src/calibre/manual/images/tag_browser.png | Bin 0 -> 42321 bytes 2 files changed, 12 insertions(+) create mode 100644 src/calibre/manual/images/tag_browser.png diff --git a/src/calibre/manual/gui.rst b/src/calibre/manual/gui.rst index 3718f830f3..67d67c6383 100644 --- a/src/calibre/manual/gui.rst +++ b/src/calibre/manual/gui.rst @@ -413,6 +413,18 @@ The Book Details display shows you extra information and the cover for the curre .. _jobs: +.. _tag_browser: + +Tag Browser +------------- +.. image:: images/tag_browser.png + +The Tag Browser allows you to easily browse your collection by Author/Tags/Series/etc. If you click on any Item in the Tag Browser, for example, the Author name, Isaac Asimov, then the list of books to the right is restricted to books by that author. Clicking once again on Isaac Asimov will restrict the list of books to books not by Isaac Asimov. A third click will remove the restriction. If you hold down the Ctrl or Shift keys and click on multiple items, then restrictions based on multiple items are created. For example you could Hold Ctrl and click on the tags History and Europe for find books on European history. The Tag Browser works by constructing search expressions that are automatically entered into the Search bar. It is a good way to learn how to construct basic search expressions. + +There is a search bar at the top of the Tag Browser that allows you to easily find any item in the Tag Browser. In addition, you can right click on any item and choose to hide it or rename it or open a "Manage x" dialog that allows you to manage items of that kind. For example the "Manage Authors" dialog allows you to rename authors and control how their names are sorted. + +For convenience, you can drag and drop books from the book list to items in the Tag Browser and that item will be automatically applied to the dropped books. For example, dragging a book to Isaac Asimov will set the author of that book to Isaac Asimov or dragging it to the tag History will add the tag History to its tags. + Jobs ----- .. image:: images/jobs.png diff --git a/src/calibre/manual/images/tag_browser.png b/src/calibre/manual/images/tag_browser.png new file mode 100644 index 0000000000000000000000000000000000000000..818ca6964d71e0846ddb922fce3b9ef7db01d8b2 GIT binary patch literal 42321 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 P0WT$ab-5Z@^YDKISm>XQ literal 0 HcmV?d00001 From 128bd8aa1b9bf7a3106c3ba1a7b6baf5ef095d39 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 23 Feb 2011 14:05:54 +0000 Subject: [PATCH 17/55] Add right-click rename and create to user categories --- src/calibre/gui2/tag_view.py | 95 +++++++++++++++++++++++++++++------- 1 file changed, 77 insertions(+), 18 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 1033957656..b8ba785d66 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -73,6 +73,7 @@ class TagsView(QTreeView): # {{{ refresh_required = pyqtSignal() tags_marked = pyqtSignal(object) user_category_edit = pyqtSignal(object) + add_subcategory = pyqtSignal(object) tag_list_edit = pyqtSignal(object, object) saved_search_edit = pyqtSignal(object) author_sort_edit = pyqtSignal(object, object) @@ -218,6 +219,9 @@ class TagsView(QTreeView): # {{{ if action == 'manage_categories': self.user_category_edit.emit(category) return + if action == 'add_subcategory': + self.add_subcategory.emit(category) + return if action == 'search': self._toggle(index, set_to=search_state) return @@ -303,9 +307,21 @@ class TagsView(QTreeView): # {{{ search_state=TAG_SEARCH_STATES['mark_minus'], index=index)) self.context_menu.addSeparator() + elif key.startswith('@'): + if item.can_edit: + self.context_menu.addAction(_('Rename %s')%key[1:], + partial(self.context_menu_handler, action='edit_item', + category=key, index=index)) + self.context_menu.addAction(self.search_icon, + _('Add sub-category to %s')%key[1:], + partial(self.context_menu_handler, + action='add_subcategory', category=key)) + self.context_menu.addSeparator() # Hide/Show/Restore categories - self.context_menu.addAction(_('Hide category %s') % category, - partial(self.context_menu_handler, action='hide', category=category)) + if not key.startswith('@') or key.find('.') < 0: + self.context_menu.addAction(_('Hide category %s') % category, + partial(self.context_menu_handler, action='hide', + category=category)) if self.hidden_categories: m = self.context_menu.addMenu(_('Show category')) for col in sorted(self.hidden_categories, key=sort_key): @@ -615,9 +631,10 @@ class TagsModel(QAbstractItemModel): # {{{ else: tt = _(u'The lookup/search name is "{0}"').format(r) - if r.startswith('@') and r.find('.') >= 0: + if r.startswith('@'): path_parts = [p.strip() for p in r.split('.') if p.strip()] path = '' + last_category_node = self.root_item for i,p in enumerate(path_parts): path += p if path not in category_node_map: @@ -629,6 +646,7 @@ class TagsModel(QAbstractItemModel): # {{{ last_category_node = node category_node_map[path] = node self.category_nodes.append(node) + node.can_edit = i == (len(path_parts) - 1) else: last_category_node = category_node_map[path] path += '.' @@ -986,6 +1004,37 @@ class TagsModel(QAbstractItemModel): # {{{ _('An item cannot be set to nothing. Delete it instead.')).exec_() return False item = index.internalPointer() + if item.type == TagTreeItem.CATEGORY and item.category_key.startswith('@'): + user_cats = self.db.prefs.get('user_categories', {}) + ckey = item.category_key[1:] + dotpos = ckey.rfind('.') + if dotpos < 0: + nkey = val + else: + nkey = ckey[:dotpos+1] + val + for c in user_cats: + if c.startswith(ckey): + 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) + return False + user_cats[nkey] = user_cats[ckey] + del user_cats[ckey] + elif c[len(ckey)] == '.': + rest = c[len(ckey):] + if (nkey + rest) in user_cats: + error_dialog(self.tags_view, _('Rename user category'), + _('The name %s is already used')%(nkey+rest), show=True) + return False + user_cats[nkey + rest] = user_cats[ckey + rest] + del user_cats[ckey + rest] + self.db.prefs.set('user_categories', user_cats) + self.tags_view.set_new_model() + # must not use 'self' below because the model has changed! + p = self.tags_view.model().find_category_node('@' + nkey) + self.tags_view.model().show_item_at_path(p) + return True itm = item.parent while itm.type != TagTreeItem.CATEGORY: itm = itm.parent @@ -1118,15 +1167,6 @@ class TagsModel(QAbstractItemModel): # {{{ for t in tag_item.children: process_tag(t) -# 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.type == TagTreeItem.CATEGORY: -# process_level(tag_index) -# else: -# process_tag(tag_index, tag_item) - for t in self.root_item.children: process_tag(t) @@ -1239,7 +1279,7 @@ class TagsModel(QAbstractItemModel): # {{{ break return self.path_found - def find_category_node(self, key): + def find_category_node(self, key, parent=QModelIndex()): ''' Search for an category node (a top-level node) in the tags browser list that matches the key (exact case-insensitive match). Returns the path to @@ -1248,11 +1288,17 @@ class TagsModel(QAbstractItemModel): # {{{ if not key: return None - for i in xrange(self.rowCount(QModelIndex())): - idx = self.index(i, 0, QModelIndex()) - ckey = idx.internalPointer().category_key - if strcmp(ckey, key) == 0: - return self.path_for_index(idx) + for i in xrange(self.rowCount(parent)): + idx = self.index(i, 0, parent) + node = idx.internalPointer() + if node.type == TagTreeItem.CATEGORY: + ckey = node.category_key + if strcmp(ckey, key) == 0: + return self.path_for_index(idx) + if len(node.children): + v = self.find_category_node(key, idx) + if v is not None: + return v return None def show_item_at_path(self, path, box=False): @@ -1307,6 +1353,7 @@ class TagBrowserMixin(object): # {{{ self.tags_view.tags_marked.connect(self.search.set_search_string) self.tags_view.tag_list_edit.connect(self.do_tags_list_edit) self.tags_view.user_category_edit.connect(self.do_user_categories_edit) + self.tags_view.add_subcategory.connect(self.do_add_subcategory) self.tags_view.saved_search_edit.connect(self.do_saved_search_edit) self.tags_view.author_sort_edit.connect(self.do_author_sort_edit) self.tags_view.tag_item_renamed.connect(self.do_tag_item_renamed) @@ -1315,6 +1362,18 @@ class TagBrowserMixin(object): # {{{ self.edit_categories.clicked.connect(lambda x: self.do_user_categories_edit()) + def do_add_subcategory(self, on_category=None): + db = self.library_view.model().db + user_cats = db.prefs.get('user_categories', {}) + new_cat = on_category[1:] + '.New Category' + user_cats[new_cat] = [] + db.prefs.set('user_categories', user_cats) + self.tags_view.set_new_model() + m = self.tags_view.model() + idx = m.index_for_path(m.find_category_node('@' + new_cat)) + m.show_item_at_index(idx) + self.tags_view.edit(idx) + def do_user_categories_edit(self, on_category=None): db = self.library_view.model().db d = TagCategories(self, db, on_category) From 06ca716b26d402837d43c7cbda11f2d341eb681b Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 23 Feb 2011 14:30:54 +0000 Subject: [PATCH 18/55] More tag browser documentation --- src/calibre/manual/gui.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/calibre/manual/gui.rst b/src/calibre/manual/gui.rst index 67d67c6383..d2de87fa91 100644 --- a/src/calibre/manual/gui.rst +++ b/src/calibre/manual/gui.rst @@ -425,6 +425,13 @@ There is a search bar at the top of the Tag Browser that allows you to easily fi For convenience, you can drag and drop books from the book list to items in the Tag Browser and that item will be automatically applied to the dropped books. For example, dragging a book to Isaac Asimov will set the author of that book to Isaac Asimov or dragging it to the tag History will add the tag History to its tags. +The outer-level items in the tag browser such as Authors and Series are called categories. You can create your own categories, called User Categories, which are useful for organizing items. For example, you can use the user categories editor (push the Manage User Categories button) to create a user category called Favorite Authors, then put the items for your favorites into the category. User categories act like built-in categories; you can click on items to search for them. You can search for all items in a category by right-clicking on the category name and choosing "Search for books in ...". + +User categories can have sub-categories. For example, the user category Favorites.Authors is a sub-category of Favorites. You might also have Favorites.Series, in which case there will be two sub-categories under Favorites. Sub-categories can be created using Manage User Categories by entering names like the Favorites example. They can also be created by right-clicking on a user category, choosing "Add sub-category to ...", and entering the category name. + +It is also possible to create hierarchies inside some of the built-in categories (the text categories). These hierarchies show with the small triangle permitting the sub-items to be hidden. To use hierarchies in a category, you must first go to Preferences / Look & Feel and enter the category name(s) into the "Categories with hierarchical items" box. Once this is done, items in that category that contain periods will be shown using the small triangle. For example, assume you create a custom column called Genre and indicate that it contains hierarchical items. Once done, items such as Mystery.Thriller and Mystery.English will display as Mystery with the small triangle next to it. Clicking on the triangle will show Thriller and English as sub-items. + + Jobs ----- .. image:: images/jobs.png From a71599ba7b37f7fda5878d6a36f62ad3e156bf63 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 23 Feb 2011 14:46:37 +0000 Subject: [PATCH 19/55] Two bugs in the new categorization stuff: 1) if a user created a new user category but didn't rename it, the next create would throw an exception 2) it was possible to specify that invalid fields such as author contain hierarchies --- src/calibre/gui2/tag_view.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 733662c7ec..6cf1eaa448 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -880,11 +880,12 @@ class TagsModel(QAbstractItemModel): # {{{ if cat_len <= 0: return ((collapse_letter, collapse_letter_sk)) + fm = self.db.field_metadata[key] 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' \ + not fm['is_custom'] and \ + not fm['kind'] == 'user' \ else False - tt = key if self.db.field_metadata[key]['kind'] == 'user' else None + tt = key if fm['kind'] == 'user' else None for idx,tag in enumerate(data[key]): if clear_rating: tag.avg_rating = None @@ -931,9 +932,11 @@ class TagsModel(QAbstractItemModel): # {{{ node_parent = category components = [t for t in tag.name.split('.')] - if key not in self.db.prefs.get('categories_using_hierarchy', []) \ - or len(components) == 1 or \ - self.db.field_metadata[key]['kind'] == 'user': + if key in ['authors', 'publisher', 'news', 'formats'] or \ + key not in self.db.prefs.get('categories_using_hierarchy', []) or\ + len(components) == 1 or \ + fm['kind'] == 'user' or \ + fm['datatype'] not in ['text', 'series', 'enumeration']: self.beginInsertRows(category_index, 999999, 1) TagTreeItem(parent=node_parent, data=tag, tooltip=tt, icon_map=self.icon_state_map) @@ -1365,13 +1368,25 @@ class TagBrowserMixin(object): # {{{ def do_add_subcategory(self, on_category=None): db = self.library_view.model().db user_cats = db.prefs.get('user_categories', {}) - new_cat = on_category[1:] + '.' + _('New Category').replace('.', '') + + # Ensure that the temporary name we will use is not already there + i = 0 + new_name = _('New Category').replace('.', '') + n = new_name + while True: + new_cat = on_category[1:] + '.' + n + if new_cat not in user_cats: + break + i += 1 + n = new_name + unicode(i) + # Add the new category user_cats[new_cat] = [] db.prefs.set('user_categories', user_cats) self.tags_view.set_new_model() m = self.tags_view.model() idx = m.index_for_path(m.find_category_node('@' + new_cat)) m.show_item_at_index(idx) + # Open the editor on the new item to rename it self.tags_view.edit(idx) def do_user_categories_edit(self, on_category=None): From a64f4aeb0dcb50f426c2f9085fd795ca6ba07afc Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 23 Feb 2011 07:49:48 -0700 Subject: [PATCH 20/55] Skeleton for D&D in Tag Browser --- src/calibre/gui2/tag_view.py | 41 ++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 733662c7ec..5f34da9ecf 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -7,7 +7,7 @@ __docformat__ = 'restructuredtext en' Browsing book collection by tags. ''' -import traceback, copy +import traceback, copy, cPickle from itertools import izip from functools import partial @@ -16,7 +16,7 @@ from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, QFont, QSize, \ QIcon, QPoint, QVBoxLayout, QHBoxLayout, QComboBox, QTimer,\ QAbstractItemModel, QVariant, QModelIndex, QMenu, QFrame,\ QPushButton, QWidget, QItemDelegate, QString, QLabel, \ - QShortcut, QKeySequence, SIGNAL + QShortcut, QKeySequence, SIGNAL, QMimeData from calibre.ebooks.metadata import title_sort from calibre.gui2 import config, NONE, gprefs @@ -95,7 +95,8 @@ class TagsView(QTreeView): # {{{ self.setItemDelegate(TagDelegate(self)) self.made_connections = False self.setAcceptDrops(True) - self.setDragDropMode(self.DropOnly) + self.setDragEnabled(True) + self.setDragDropMode(self.DragDrop) self.setDropIndicatorShown(True) self.setAutoExpandDelay(500) self.pane_is_visible = False @@ -406,12 +407,13 @@ class TagsView(QTreeView): # {{{ fm_dest = self.db.metadata_for_field(item.category_key) if fm_dest['kind'] == 'user': md = event.mimeData() - fm_src = self.db.metadata_for_field(md.column_name) - if md.column_name in ['authors', 'publisher', 'series'] or \ - (fm_src['is_custom'] and - fm_src['datatype'] in ['series', 'text'] and - not fm_src['is_multiple']): - self.setDropIndicatorShown(True) + if hasattr(md, 'column_name'): + fm_src = self.db.metadata_for_field(md.column_name) + if md.column_name in ['authors', 'publisher', 'series'] or \ + (fm_src['is_custom'] and + fm_src['datatype'] in ['series', 'text'] and + not fm_src['is_multiple']): + self.setDropIndicatorShown(True) def clear(self): if self.model(): @@ -664,10 +666,26 @@ class TagsModel(QAbstractItemModel): # {{{ self.db = self.root_item = None def mimeTypes(self): - return ["application/calibre+from_library"] + return ["application/calibre+from_library", + 'application/calibre+from_tag_browser'] + + def mimeData(self, indexes): + data = [] + for idx in indexes: + if idx.isValid(): + # get some useful serializable data + name = unicode(self.data(idx, Qt.DisplayRole).toString()) + data.append(name) + else: + data.append(None) + raw = bytearray(cPickle.dumps(data, -1)) + ans = QMimeData() + ans.setData('application/calibre+from_tag_browser', raw) + return ans def dropMimeData(self, md, action, row, column, parent): - if not md.hasFormat("application/calibre+from_library") or \ + fmts = set([unicode(x) for x in md.formats()]) + if not fmts.intersection(set(self.mimeTypes())) or \ action != Qt.CopyAction: return False idx = parent @@ -1081,6 +1099,7 @@ class TagsModel(QAbstractItemModel): # {{{ if index.isValid(): node = self.data(index, Qt.UserRole) if node.type == TagTreeItem.TAG: + ans |= Qt.ItemIsDragEnabled fm = self.db.metadata_for_field(node.tag.category) if node.tag.category in \ ('tags', 'series', 'authors', 'rating', 'publisher') or \ From 7839e164c43b3143c186a34c83292062d8c778d0 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 23 Feb 2011 08:25:08 -0700 Subject: [PATCH 21/55] ... --- src/calibre/customize/ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/customize/ui.py b/src/calibre/customize/ui.py index e9feacc67e..0f5508a89e 100644 --- a/src/calibre/customize/ui.py +++ b/src/calibre/customize/ui.py @@ -583,7 +583,7 @@ def main(args=sys.argv): if remove_plugin(opts.remove_plugin): print 'Plugin removed' else: - print 'No custom pluginnamed', opts.remove_plugin + print 'No custom plugin named', opts.remove_plugin if opts.customize_plugin is not None: name, custom = opts.customize_plugin.split(',') plugin = find_plugin(name.strip()) From 9fdfe8311b659c3e75e42a4044fee8ee71ccab3e Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 23 Feb 2011 16:57:56 +0000 Subject: [PATCH 22/55] Drag tag browser items & drop on user categories. --- src/calibre/gui2/tag_view.py | 69 +++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 30fa958ecb..378d22d0f6 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -398,14 +398,18 @@ class TagsView(QTreeView): # {{{ index = self.indexAt(event.pos()) if not index.isValid(): return + src_is_tb = event.mimeData().hasFormat('application/calibre+from_tag_browser') item = index.internalPointer() flags = self._model.flags(index) if item.type == TagTreeItem.TAG and flags & Qt.ItemIsDropEnabled: - self.setDropIndicatorShown(True) + self.setDropIndicatorShown(not src_is_tb) else: if item.type == TagTreeItem.CATEGORY: fm_dest = self.db.metadata_for_field(item.category_key) if fm_dest['kind'] == 'user': + if src_is_tb: + self.setDropIndicatorShown(True) + return md = event.mimeData() if hasattr(md, 'column_name'): fm_src = self.db.metadata_for_field(md.column_name) @@ -674,8 +678,17 @@ class TagsModel(QAbstractItemModel): # {{{ for idx in indexes: if idx.isValid(): # get some useful serializable data - name = unicode(self.data(idx, Qt.DisplayRole).toString()) - data.append(name) + node = idx.internalPointer() + if node.type == TagTreeItem.CATEGORY: + d = (node.type, node.py_name, node.category_key) + else: + t = node.tag + p = node + while p.type != TagTreeItem.CATEGORY: + p = p.parent + d = (node.type, p.category_key, + getattr(t, 'original_name', t.name), t.category, t.id) + data.append(d) else: data.append(None) raw = bytearray(cPickle.dumps(data, -1)) @@ -688,6 +701,53 @@ class TagsModel(QAbstractItemModel): # {{{ if not fmts.intersection(set(self.mimeTypes())) or \ action != Qt.CopyAction: return False + if "application/calibre+from_library" in fmts: + return self.do_drop_from_library(md, action, row, column, parent) + elif 'application/calibre+from_tag_browser' in fmts: + return self.do_drop_from_tag_browser(md, action, row, column, parent) + + def do_drop_from_tag_browser(self, md, action, row, column, parent): + if not parent.isValid(): + return False + dest = parent.internalPointer() + if dest.type != TagTreeItem.CATEGORY: + return False + if not md.hasFormat('application/calibre+from_tag_browser'): + return False + data = str(md.data('application/calibre+from_tag_browser')) + src = cPickle.loads(data) + for s in src: + if s[0] != TagTreeItem.TAG: + return False + user_cats = self.db.prefs.get('user_categories', {}) + for s in src: + src_parent, src_name, src_cat = s[1:4] + src_parent = src_parent[1:] + dest_key = dest.category_key[1:] + if dest_key not in user_cats: + continue + new_cat = [] + # delete the item if the source is a user category + if src_parent in user_cats: + 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 + # Now add the item to the destination user category + add_it = True + for tup in user_cats[dest_key]: + if src_name == tup[0] and src_cat == tup[1]: + add_it = False + if add_it: + user_cats[dest_key].append([src_name, src_cat, 0]) + self.db.prefs.set('user_categories', user_cats) + path = self.path_for_index(parent) + self.tags_view.set_new_model() + self.tags_view.model().show_item_at_path(path) + return True + + def do_drop_from_library(self, md, action, row, column, parent): idx = parent if idx.isValid(): node = self.data(idx, Qt.UserRole) @@ -1102,7 +1162,8 @@ class TagsModel(QAbstractItemModel): # {{{ if index.isValid(): node = self.data(index, Qt.UserRole) if node.type == TagTreeItem.TAG: - ans |= Qt.ItemIsDragEnabled + if getattr(node.tag, 'can_edit', True): + ans |= Qt.ItemIsDragEnabled fm = self.db.metadata_for_field(node.tag.category) if node.tag.category in \ ('tags', 'series', 'authors', 'rating', 'publisher') or \ From 418d10eace45d228804a3c8b731da2c532180666 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 23 Feb 2011 10:27:16 -0700 Subject: [PATCH 23/55] MOBI Input: Do not speciy text-align for every paragraph. Fixes text-align inheritance issues for newer MOBIs with nested divs. Fixes #9098 (Problem with conversion of align="center") --- src/calibre/ebooks/mobi/reader.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/calibre/ebooks/mobi/reader.py b/src/calibre/ebooks/mobi/reader.py index 7a566776d7..9c52a18691 100644 --- a/src/calibre/ebooks/mobi/reader.py +++ b/src/calibre/ebooks/mobi/reader.py @@ -242,9 +242,11 @@ class MobiReader(object): self.debug = debug self.embedded_mi = None self.base_css_rules = textwrap.dedent(''' - blockquote { margin: 0em 0em 0em 2em; text-align: justify } + body { text-align: justify } - p { margin: 0em; text-align: justify; text-indent: 1.5em } + blockquote { margin: 0em 0em 0em 2em; } + + p { margin: 0em; text-indent: 1.5em } .bold { font-weight: bold } From 725634062bd492e75d2018016bdcd89e91938f7e Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 23 Feb 2011 20:58:01 +0000 Subject: [PATCH 24/55] 1) Add copy/move to drag onto user category 2) Have manage categories create empty categories for all intermediate nodes in a category tree --- src/calibre/gui2/dialogs/tag_categories.py | 6 +++ src/calibre/gui2/tag_view.py | 53 +++++++++++++--------- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/calibre/gui2/dialogs/tag_categories.py b/src/calibre/gui2/dialogs/tag_categories.py index b8a7e67c72..af6632bb02 100644 --- a/src/calibre/gui2/dialogs/tag_categories.py +++ b/src/calibre/gui2/dialogs/tag_categories.py @@ -232,6 +232,12 @@ class TagCategories(QDialog, Ui_TagCategories): def accept(self): self.save_category() + for cat in sorted(self.categories.keys(), key=sort_key): + components = cat.split('.') + for i in range(0,len(components)): + c = '.'.join(components[0:i+1]) + if c not in self.categories: + self.categories[c] = [] QDialog.accept(self) def save_category(self): diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 378d22d0f6..25726369e7 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -403,21 +403,27 @@ class TagsView(QTreeView): # {{{ flags = self._model.flags(index) if item.type == TagTreeItem.TAG and flags & Qt.ItemIsDropEnabled: self.setDropIndicatorShown(not src_is_tb) - else: - if item.type == TagTreeItem.CATEGORY: - fm_dest = self.db.metadata_for_field(item.category_key) - if fm_dest['kind'] == 'user': - if src_is_tb: + return + if item.type == TagTreeItem.CATEGORY: + fm_dest = self.db.metadata_for_field(item.category_key) + if fm_dest['kind'] == 'user': + if src_is_tb: + if event.dropAction() == Qt.MoveAction: + data = str(event.mimeData().data('application/calibre+from_tag_browser')) + src = cPickle.loads(data) + for s in src: + if s[0] == TagTreeItem.TAG and not s[1].startswith('@'): + return + self.setDropIndicatorShown(True) + return + md = event.mimeData() + if hasattr(md, 'column_name'): + fm_src = self.db.metadata_for_field(md.column_name) + if md.column_name in ['authors', 'publisher', 'series'] or \ + (fm_src['is_custom'] and + fm_src['datatype'] in ['series', 'text'] and + not fm_src['is_multiple']): self.setDropIndicatorShown(True) - return - md = event.mimeData() - if hasattr(md, 'column_name'): - fm_src = self.db.metadata_for_field(md.column_name) - if md.column_name in ['authors', 'publisher', 'series'] or \ - (fm_src['is_custom'] and - fm_src['datatype'] in ['series', 'text'] and - not fm_src['is_multiple']): - self.setDropIndicatorShown(True) def clear(self): if self.model(): @@ -698,10 +704,11 @@ class TagsModel(QAbstractItemModel): # {{{ def dropMimeData(self, md, action, row, column, parent): fmts = set([unicode(x) for x in md.formats()]) - if not fmts.intersection(set(self.mimeTypes())) or \ - action != Qt.CopyAction: + if not fmts.intersection(set(self.mimeTypes())): return False if "application/calibre+from_library" in fmts: + if action != Qt.CopyAction: + return False return self.do_drop_from_library(md, action, row, column, parent) elif 'application/calibre+from_tag_browser' in fmts: return self.do_drop_from_tag_browser(md, action, row, column, parent) @@ -727,8 +734,8 @@ class TagsModel(QAbstractItemModel): # {{{ if dest_key not in user_cats: continue new_cat = [] - # delete the item if the source is a user category - if src_parent in user_cats: + # delete the item if the source is a user category and action is move + if src_parent in user_cats and action == Qt.MoveAction: for tup in user_cats[src_parent]: if src_name == tup[0] and src_cat == tup[1]: continue @@ -742,9 +749,13 @@ class TagsModel(QAbstractItemModel): # {{{ if add_it: user_cats[dest_key].append([src_name, src_cat, 0]) self.db.prefs.set('user_categories', user_cats) - path = self.path_for_index(parent) self.tags_view.set_new_model() - self.tags_view.model().show_item_at_path(path) + # Must work with the new model here + m = self.tags_view.model() + path = m.find_category_node('@' + src_parent) + idx = m.index_for_path(path) + self.tags_view.setExpanded(idx, True) + m.show_item_at_index(idx) return True def do_drop_from_library(self, md, action, row, column, parent): @@ -1175,7 +1186,7 @@ class TagsModel(QAbstractItemModel): # {{{ return ans def supportedDropActions(self): - return Qt.CopyAction + return Qt.CopyAction|Qt.MoveAction def path_for_index(self, index): ans = [] From 8b7b831d8081ef06692d46e1349557079584d36f Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 23 Feb 2011 22:05:04 +0000 Subject: [PATCH 25/55] Fix regression in user category search. --- src/calibre/library/caches.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index 318183eb10..4f5a034222 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -426,6 +426,8 @@ class ResultCache(SearchQueryParser): # {{{ if l > 0: alt_loc = location[0:l] alt_item = location[l+1:] + else: + alt_loc = None for key in user_cats: if key == location or key.startswith(location + '.'): for (item, category, ign) in user_cats[key]: From b223290b14bd3065b1b8690d7adad702e28dba7e Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 23 Feb 2011 22:14:09 +0000 Subject: [PATCH 26/55] Make manage categories focus on the category when sub-categories are right-clicked. --- src/calibre/gui2/tag_view.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index fb2cbf81f7..d5b62f8efc 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -356,10 +356,11 @@ class TagsView(QTreeView): # {{{ # Always show the user categories editor self.context_menu.addSeparator() - if category in self.db.prefs.get('user_categories', {}).keys(): + if key.startswith('@') and \ + key[1:] in self.db.prefs.get('user_categories', {}).keys(): self.context_menu.addAction(_('Manage User Categories'), partial(self.context_menu_handler, action='manage_categories', - category=category)) + category=key[1:])) else: self.context_menu.addAction(_('Manage User Categories'), partial(self.context_menu_handler, action='manage_categories', From fe064ee18d116676f4c0444fd0398ea66d980b8e Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 23 Feb 2011 18:09:47 -0700 Subject: [PATCH 27/55] Fix #9126 (Xperia X10 (android 2.1) phone not detected) --- src/calibre/devices/android/driver.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/calibre/devices/android/driver.py b/src/calibre/devices/android/driver.py index 99679283a7..95633cbe58 100644 --- a/src/calibre/devices/android/driver.py +++ b/src/calibre/devices/android/driver.py @@ -74,6 +74,9 @@ class ANDROID(USBMS): # T-Mobile 0x0408 : { 0x03ba : [0x0109], }, + # Xperia + 0x13d3 : { 0x3304 : [0x0001, 0x0002] }, + } EBOOK_DIR_MAIN = ['eBooks/import', 'wordplayer/calibretransfer', 'Books'] EXTRA_CUSTOMIZATION_MESSAGE = _('Comma separated list of directories to ' @@ -83,7 +86,7 @@ class ANDROID(USBMS): VENDOR_NAME = ['HTC', 'MOTOROLA', 'GOOGLE_', 'ANDROID', 'ACER', 'GT-I5700', 'SAMSUNG', 'DELL', 'LINUX', 'GOOGLE', 'ARCHOS', - 'TELECHIP', 'HUAWEI', 'T-MOBILE', ] + 'TELECHIP', 'HUAWEI', 'T-MOBILE', 'SEMC'] WINDOWS_MAIN_MEM = ['ANDROID_PHONE', 'A855', 'A853', 'INC.NEXUS_ONE', '__UMS_COMPOSITE', '_MB200', 'MASS_STORAGE', '_-_CARD', 'SGH-I897', 'GT-I9000', 'FILE-STOR_GADGET', 'SGH-T959', 'SAMSUNG_ANDROID', From f42f736cc1d8d715450fb99e19280d465ed17533 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 23 Feb 2011 20:17:51 -0700 Subject: [PATCH 28/55] Both nytimes recipes now need subscriptions --- resources/recipes/nytimes.recipe | 4 ++-- resources/recipes/nytimes_sub.recipe | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/resources/recipes/nytimes.recipe b/resources/recipes/nytimes.recipe index 7e313e5727..b2043bb463 100644 --- a/resources/recipes/nytimes.recipe +++ b/resources/recipes/nytimes.recipe @@ -88,8 +88,8 @@ class NYTimes(BasicNewsRecipe): if headlinesOnly: title='New York Times Headlines' - description = 'Headlines from the New York Times' - needs_subscription = False + description = 'Headlines from the New York Times. Needs a subscription from http://www.nytimes.com' + needs_subscription = True elif webEdition: title='New York Times (Web)' description = 'New York Times on the Web' diff --git a/resources/recipes/nytimes_sub.recipe b/resources/recipes/nytimes_sub.recipe index 4077065d91..d24307c887 100644 --- a/resources/recipes/nytimes_sub.recipe +++ b/resources/recipes/nytimes_sub.recipe @@ -96,18 +96,18 @@ class NYTimes(BasicNewsRecipe): if headlinesOnly: title='New York Times Headlines' description = 'Headlines from the New York Times' - needs_subscription = False + needs_subscription = True elif webEdition: title='New York Times (Web)' description = 'New York Times on the Web' needs_subscription = True elif replaceKindleVersion: - title='The New York Times' + title='The New York Times' description = 'Today\'s New York Times' needs_subscription = True else: title='New York Times' - description = 'Today\'s New York Times' + description = 'Today\'s New York Times. Needs subscription from http://www.nytimes.com' needs_subscription = True @@ -676,7 +676,7 @@ class NYTimes(BasicNewsRecipe): if hlines: for hline in hlines: hline.extract() - + #find all section headers hlines = runAround.findAll('h6') if hlines: From f5f1c87cd58b85a4ccf9c0406e8f539de191bcc9 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 09:03:10 +0000 Subject: [PATCH 29/55] Several things: 1) can remove an item from a user category 2) can remove a user category 3) renaming an item will also rename the item in all user categories 4) make find_item_node work in the face of hierarchies (user cats and items) 5) change set_new_model() to recount() where possible to avoid closing expanded trees --- src/calibre/gui2/tag_view.py | 207 ++++++++++++++++++++++++++--------- 1 file changed, 156 insertions(+), 51 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index d5b62f8efc..449a94d011 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -25,7 +25,7 @@ from calibre.utils.config import tweaks from calibre.utils.icu import sort_key, upper, lower, strcmp from calibre.utils.search_query_parser import saved_searches from calibre.utils.formatter import eval_formatter -from calibre.gui2 import error_dialog +from calibre.gui2 import error_dialog, question_dialog from calibre.gui2.dialogs.confirm_delete import confirm from calibre.gui2.dialogs.tag_categories import TagCategories from calibre.gui2.dialogs.tag_list_editor import TagListEditor @@ -73,6 +73,8 @@ class TagsView(QTreeView): # {{{ refresh_required = pyqtSignal() tags_marked = pyqtSignal(object) user_category_edit = pyqtSignal(object) + user_category_delete= pyqtSignal(object) + del_user_cat_item = pyqtSignal(object, object, object) add_subcategory = pyqtSignal(object) tag_list_edit = pyqtSignal(object, object) saved_search_edit = pyqtSignal(object) @@ -105,6 +107,7 @@ class TagsView(QTreeView): # {{{ else: self.collapse_model = gprefs['tags_browser_partition_method'] self.search_icon = QIcon(I('search.png')) + self.user_category_icon = QIcon(I('tb_folder.png')) def set_pane_is_visible(self, to_what): pv = self.pane_is_visible @@ -220,15 +223,21 @@ class TagsView(QTreeView): # {{{ if action == 'manage_categories': self.user_category_edit.emit(category) return - if action == 'add_subcategory': - self.add_subcategory.emit(category) - return if action == 'search': self._toggle(index, set_to=search_state) return + if action == 'add_subcategory': + self.add_subcategory.emit(key) + return if action == 'search_category': self.tags_marked.emit(key + ':' + search_state) return + if action == 'delete_user_category': + self.user_category_delete.emit(key) + return + if action == 'delete_item_from_user_category': + self.del_user_cat_item.emit(key, index.name, index.category) + return if action == 'manage_searches': self.saved_search_edit.emit(category) return @@ -259,14 +268,11 @@ class TagsView(QTreeView): # {{{ if index.isValid(): item = index.internalPointer() - tag_name = '' + tag = None if item.type == TagTreeItem.TAG: - tag_item = item - t = item.tag - tag_name = t.name - tag_id = t.id - can_edit = getattr(t, 'can_edit', True) + tag = item.tag + can_edit = getattr(tag, 'can_edit', True) while item.type != TagTreeItem.CATEGORY: item = item.parent @@ -281,42 +287,50 @@ class TagsView(QTreeView): # {{{ return True # Did the user click on a leaf node? - if tag_name: + if tag: # If the user right-clicked on an editable item, then offer # the possibility of renaming that item. - if can_edit and \ - key in ['authors', 'tags', 'series', 'publisher', 'search'] or \ - (self.db.field_metadata[key]['is_custom'] and \ - self.db.field_metadata[key]['datatype'] != 'rating'): + if can_edit: # Add the 'rename' items - self.context_menu.addAction(_('Rename %s')%tag_name, + self.context_menu.addAction(_('Rename %s')%tag.name, partial(self.context_menu_handler, action='edit_item', - category=tag_item, index=index)) + index=index)) if key == 'authors': - self.context_menu.addAction(_('Edit sort for %s')%tag_name, + self.context_menu.addAction(_('Edit sort for %s')%tag.name, partial(self.context_menu_handler, - action='edit_author_sort', index=tag_id)) + action='edit_author_sort', index=tag.id)) + if key.startswith('@'): + self.context_menu.addAction(self.user_category_icon, + _('Remove %s from category %s')%(tag.name, item.py_name), + partial(self.context_menu_handler, + action='delete_item_from_user_category', + key = key, index = tag)) # Add the search for value items self.context_menu.addAction(self.search_icon, - _('Search for %s')%tag_name, + _('Search for %s')%tag.name, partial(self.context_menu_handler, action='search', search_state=TAG_SEARCH_STATES['mark_plus'], index=index)) self.context_menu.addAction(self.search_icon, - _('Search for everything but %s')%tag_name, + _('Search for everything but %s')%tag.name, partial(self.context_menu_handler, action='search', search_state=TAG_SEARCH_STATES['mark_minus'], index=index)) self.context_menu.addSeparator() elif key.startswith('@'): if item.can_edit: - self.context_menu.addAction(_('Rename %s')%key[1:], + self.context_menu.addAction(self.user_category_icon, + _('Rename %s')%item.py_name, partial(self.context_menu_handler, action='edit_item', - category=key, index=index)) - self.context_menu.addAction(self.search_icon, - _('Add sub-category to %s')%key[1:], + index=index)) + self.context_menu.addAction(self.user_category_icon, + _('Add sub-category to %s')%item.py_name, partial(self.context_menu_handler, - action='add_subcategory', category=key)) + action='add_subcategory', key=key)) + self.context_menu.addAction(self.user_category_icon, + _('Delete user category %s')%item.py_name, + partial(self.context_menu_handler, + action='delete_user_category', key=key)) self.context_menu.addSeparator() # Hide/Show/Restore categories if not key.startswith('@') or key.find('.') < 0: @@ -345,14 +359,14 @@ class TagsView(QTreeView): # {{{ self.db.field_metadata[key]['is_custom']: self.context_menu.addAction(_('Manage %s')%category, partial(self.context_menu_handler, action='open_editor', - category=tag_name, key=key)) + category=tag.name if tag else None, key=key)) elif key == 'authors': self.context_menu.addAction(_('Manage %s')%category, partial(self.context_menu_handler, action='edit_author_sort')) elif key == 'search': self.context_menu.addAction(_('Manage Saved Searches'), partial(self.context_menu_handler, action='manage_searches', - category=tag_name)) + category=tag.name if tag else None)) # Always show the user categories editor self.context_menu.addSeparator() @@ -534,6 +548,8 @@ class TagTreeItem(object): # {{{ def category_data(self, role): if role == Qt.DisplayRole: return QVariant(self.py_name + ' [%d]'%len(self.child_tags())) + if role == Qt.EditRole: + return QVariant(self.py_name) if role == Qt.DecorationRole: return self.icon if role == Qt.FontRole: @@ -727,8 +743,19 @@ class TagsModel(QAbstractItemModel): # {{{ for s in src: if s[0] != TagTreeItem.TAG: return False + return self.move_or_copy_item_to_user_category(src, dest, action) + + def move_or_copy_item_to_user_category(self, src, dest, action): + ''' + src is a list of tuples representing items to copy. The tuple is + (type, containing category key, full name, category key, id) + The type must be TagTreeItem.TAG + dest is the TagTreeItem node to receive the items + action is Qt.CopyAction or Qt.MoveAction + ''' user_cats = self.db.prefs.get('user_categories', {}) parent_node = None + copied_node = None for s in src: src_parent, src_name, src_cat = s[1:4] parent_node = src_parent @@ -748,6 +775,9 @@ class TagsModel(QAbstractItemModel): # {{{ continue new_cat.append(list(tup)) user_cats[src_parent] = new_cat + else: + copied_node = (src_parent, src_name) + # Now add the item to the destination user category add_it = True if not is_uc and src_cat == 'news': @@ -757,12 +787,17 @@ class TagsModel(QAbstractItemModel): # {{{ add_it = False if add_it: user_cats[dest_key].append([src_name, src_cat, 0]) + self.db.prefs.set('user_categories', user_cats) - self.tags_view.set_new_model() + self.tags_view.recount() + if parent_node is not None: - # Must work with the new model here m = self.tags_view.model() - path = m.find_category_node(parent_node) + if copied_node is not None: + path = m.find_item_node(parent_node, copied_node[1], None, + equals_match=True) + else: + path = m.find_category_node(parent_node) idx = m.index_for_path(path) self.tags_view.setExpanded(idx, True) m.show_item_at_index(idx) @@ -1031,15 +1066,17 @@ class TagsModel(QAbstractItemModel): # {{{ node_parent = category components = [t for t in tag.name.split('.')] - if key in ['authors', 'publisher', 'news', 'formats'] or \ + if key in ['authors', 'publisher', 'news', 'formats', 'rating'] or \ key not in self.db.prefs.get('categories_using_hierarchy', []) or\ len(components) == 1 or \ - fm['kind'] == 'user' or \ - fm['datatype'] not in ['text', 'series', 'enumeration']: + fm['kind'] == 'user': self.beginInsertRows(category_index, 999999, 1) TagTreeItem(parent=node_parent, data=tag, tooltip=tt, icon_map=self.icon_state_map) self.endInsertRows() + tag.can_edit = key != 'formats' and \ + self.db.field_metadata[tag.category]['datatype'] in \ + ['text', 'series', 'enumeration'] else: for i,comp in enumerate(components): child_map = dict([(t.tag.name, t) for t in node_parent.children @@ -1137,10 +1174,9 @@ class TagsModel(QAbstractItemModel): # {{{ p = self.tags_view.model().find_category_node('@' + nkey) self.tags_view.model().show_item_at_path(p) return True - itm = item.parent - while itm.type != TagTreeItem.CATEGORY: - itm = itm.parent - key = itm.category_key + + key = item.tag.category + name = item.tag.name # make certain we know about the item's category if key not in self.db.field_metadata: return False @@ -1171,6 +1207,17 @@ class TagsModel(QAbstractItemModel): # {{{ label=self.db.field_metadata[key]['label']) self.tags_view.tag_item_renamed.emit() item.tag.name = val + # rename the item in any user categories + user_cats = self.db.prefs.get('user_categories', {}) + for k in user_cats.keys(): + new_contents = [] + for tup in user_cats[k]: + if tup[0] == name and tup[1] == key: + new_contents.append([val, key, 0]) + else: + new_contents.append(tup) + user_cats[k] = new_contents + self.db.prefs.set('user_categories', user_cats) self.refresh() # Should work, because no categories can have disappeared self.show_item_at_path(path) return True @@ -1329,19 +1376,20 @@ class TagsModel(QAbstractItemModel): # {{{ name.replace(r'"', r'\"'))) return ans - def find_item_node(self, key, txt, start_path): + def find_item_node(self, key, txt, start_path, equals_match=False): ''' Search for an item (a node) in the tags browser list that matches both - the key (exact case-insensitive match) and txt (contains case- - insensitive match). Returns the path to the node. Note that paths are to - a location (second item, fourth item, 25 item), not to a node. If + the key (exact case-insensitive match) and txt (not equals_match => + case-insensitive contains match; equals_match => case_insensitive + equal match). Returns the path to the node. Note that paths are to a + location (second item, fourth item, 25 item), not to a node. If start_path is None, the search starts with the topmost node. If the tree is changed subsequent to calling this method, the path can easily refer to a different node or no node at all. ''' if not txt: return None - txt = lower(txt) + txt = lower(txt) if not equals_match else txt self.path_found = None if start_path is None: start_path = [] @@ -1353,7 +1401,9 @@ class TagsModel(QAbstractItemModel): # {{{ tag = tag_item.tag if tag is None: return False - if lower(tag.name).find(txt) >= 0: + name = getattr(tag, 'original_name', tag.name) + if (equals_match and strcmp(name, txt) == 0) or \ + (not equals_match and lower(name).find(txt) >= 0): self.path_found = path return True return False @@ -1365,15 +1415,14 @@ class TagsModel(QAbstractItemModel): # {{{ return False if path[depth] > start_path[depth]: start_path = path - if key and strcmp(category_index.internalPointer().category_key, key) != 0: - return False + my_key = category_index.internalPointer().category_key for j in xrange(self.rowCount(category_index)): tag_index = self.index(j, 0, category_index) tag_item = tag_index.internalPointer() if tag_item.type == TagTreeItem.CATEGORY: if process_level(depth+1, tag_index, start_path): return True - else: + elif not key or strcmp(key, my_key) == 0: if process_tag(depth+1, tag_index, tag_item, start_path): return True return False @@ -1457,6 +1506,8 @@ class TagBrowserMixin(object): # {{{ self.tags_view.tags_marked.connect(self.search.set_search_string) self.tags_view.tag_list_edit.connect(self.do_tags_list_edit) self.tags_view.user_category_edit.connect(self.do_user_categories_edit) + self.tags_view.user_category_delete.connect(self.do_user_category_delete) + self.tags_view.del_user_cat_item.connect(self.do_del_user_cat_item) self.tags_view.add_subcategory.connect(self.do_add_subcategory) self.tags_view.saved_search_edit.connect(self.do_saved_search_edit) self.tags_view.author_sort_edit.connect(self.do_author_sort_edit) @@ -1466,7 +1517,29 @@ class TagBrowserMixin(object): # {{{ self.edit_categories.clicked.connect(lambda x: self.do_user_categories_edit()) - def do_add_subcategory(self, on_category=None): + def do_del_user_cat_item(self, user_cat, item_name, item_category): + ''' + Delete the item (item_name, item_category) from the user category with + key user_cat. Any leading '@' characters are removed + ''' + if user_cat.startswith('@'): + user_cat = user_cat[1:] + db = self.library_view.model().db + user_cats = db.prefs.get('user_categories', {}) + if user_cat not in user_cats: + error_dialog(self.tags_view, _('Remove category'), + _('User category %s does not exist')%user_cat, + show=True) + return + new_contents = [] + for tup in user_cats[user_cat]: + if tup[0] != item_name or tup[1] != item_category: + new_contents.append(tup) + user_cats[user_cat] = new_contents + db.prefs.set('user_categories', user_cats) + self.tags_view.recount() + + def do_add_subcategory(self, on_category_key=None): db = self.library_view.model().db user_cats = db.prefs.get('user_categories', {}) @@ -1475,7 +1548,7 @@ class TagBrowserMixin(object): # {{{ new_name = _('New Category').replace('.', '') n = new_name while True: - new_cat = on_category[1:] + '.' + n + new_cat = on_category_key[1:] + '.' + n if new_cat not in user_cats: break i += 1 @@ -1500,7 +1573,39 @@ class TagBrowserMixin(object): # {{{ db.field_metadata.add_user_category('@' + k, k) db.data.change_search_locations(db.field_metadata.get_search_terms()) self.tags_view.set_new_model() - self.tags_view.recount() + + def do_user_category_delete(self, category_name): + ''' + Delete the user category named category_name. Any leading '@' is removed + ''' + if category_name.startswith('@'): + category_name = category_name[1:] + db = self.library_view.model().db + user_cats = db.prefs.get('user_categories', {}) + cat_keys = sorted(user_cats.keys(), key=sort_key) + has_children = False + found = False + for k in cat_keys: + if k == category_name: + found = True + has_children = len(user_cats[k]) + elif k.startswith(category_name + '.'): + has_children = True + if not found: + return error_dialog(self.tags_view, _('Delete user category'), + _('%s is not a user category')%category_name, show=True) + if has_children: + if not question_dialog(self.tags_view, _('Delete user category'), + _('%s contains items. Do you really ' + 'want to delete it?')%category_name): + return + for k in cat_keys: + if k == category_name: + del user_cats[k] + elif k.startswith(category_name + '.'): + del user_cats[k] + db.prefs.set('user_categories', user_cats) + self.tags_view.set_new_model() def do_tags_list_edit(self, tag, category): db=self.library_view.model().db @@ -1548,7 +1653,7 @@ class TagBrowserMixin(object): # {{{ # Clean up the library view self.do_tag_item_renamed() - self.tags_view.set_new_model() # does a refresh for free + self.tags_view.set_new_model() def do_tag_item_renamed(self): # Clean up library view and search From 91ed569caa6cd1941bf6209f0de5ce5b6c03f6a8 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 09:47:57 +0000 Subject: [PATCH 30/55] Rename and delete items in user categories after a 'manage X' operation --- src/calibre/gui2/dialogs/tag_list_editor.py | 2 + src/calibre/gui2/tag_view.py | 71 +++++++++++++++------ 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/src/calibre/gui2/dialogs/tag_list_editor.py b/src/calibre/gui2/dialogs/tag_list_editor.py index 6c3ebb22d5..cee9eb42b9 100644 --- a/src/calibre/gui2/dialogs/tag_list_editor.py +++ b/src/calibre/gui2/dialogs/tag_list_editor.py @@ -58,10 +58,12 @@ class TagListEditor(QDialog, Ui_TagListEditor): self.to_rename = {} self.to_delete = set([]) + self.original_names = {} self.all_tags = {} for k,v in data: self.all_tags[v] = k + self.original_names[k] = v for tag in sorted(self.all_tags.keys(), key=key): item = ListWidgetItem(tag) item.setData(Qt.UserRole, self.all_tags[tag]) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 449a94d011..01cda54afe 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -1207,21 +1207,54 @@ class TagsModel(QAbstractItemModel): # {{{ label=self.db.field_metadata[key]['label']) self.tags_view.tag_item_renamed.emit() item.tag.name = val - # rename the item in any user categories - user_cats = self.db.prefs.get('user_categories', {}) - for k in user_cats.keys(): - new_contents = [] - for tup in user_cats[k]: - if tup[0] == name and tup[1] == key: - new_contents.append([val, key, 0]) - else: - new_contents.append(tup) - user_cats[k] = new_contents - self.db.prefs.set('user_categories', user_cats) + self.rename_item_in_all_user_categories(name, key, val) self.refresh() # Should work, because no categories can have disappeared self.show_item_at_path(path) return True + def rename_item_in_all_user_categories(self, item_name, item_category, new_name): + ''' + Search all user categories for items named item_name with category + item_category and rename them to new_name. The caller must arrange to + redisplay the tree as appropriate (recount or set_new_model) + ''' + user_cats = self.db.prefs.get('user_categories', {}) + for k in user_cats.keys(): + new_contents = [] + for tup in user_cats[k]: + if tup[0] == item_name and tup[1] == item_category: + new_contents.append([new_name, item_category, 0]) + else: + new_contents.append(tup) + user_cats[k] = new_contents + self.db.prefs.set('user_categories', user_cats) + + def delete_item_from_all_user_categories(self, item_name, item_category): + ''' + Search all user categories for items named item_name with category + item_category and delete them. The caller must arrange to redisplay the + tree as appropriate (recount or set_new_model) + ''' + user_cats = self.db.prefs.get('user_categories', {}) + for cat in user_cats.keys(): + self.delete_item_from_user_category(cat, item_name, item_category, + user_categories=user_cats) + self.db.prefs.set('user_categories', user_cats) + + def delete_item_from_user_category(self, category, item_name, item_category, + user_categories=None): + if user_categories is not None: + user_cats = user_categories + else: + user_cats = self.db.prefs.get('user_categories', {}) + new_contents = [] + for tup in user_cats[category]: + if tup[0] != item_name or tup[1] != item_category: + new_contents.append(tup) + user_cats[category] = new_contents + if user_categories is None: + self.db.prefs.set('user_categories', user_cats) + def headerData(self, *args): return NONE @@ -1531,12 +1564,8 @@ class TagBrowserMixin(object): # {{{ _('User category %s does not exist')%user_cat, show=True) return - new_contents = [] - for tup in user_cats[user_cat]: - if tup[0] != item_name or tup[1] != item_category: - new_contents.append(tup) - user_cats[user_cat] = new_contents - db.prefs.set('user_categories', user_cats) + self.tags_view.model().delete_item_from_user_category(user_cat, + item_name, item_category) self.tags_view.recount() def do_add_subcategory(self, on_category_key=None): @@ -1632,6 +1661,8 @@ class TagBrowserMixin(object): # {{{ if d.result() == d.Accepted: to_rename = d.to_rename # dict of new text to old id to_delete = d.to_delete # list of ids + orig_name = d.original_names # dict of id: name + rename_func = None if category == 'tags': rename_func = db.rename_tag @@ -1645,15 +1676,19 @@ class TagBrowserMixin(object): # {{{ else: rename_func = partial(db.rename_custom_item, label=cc_label) delete_func = partial(db.delete_custom_item_using_id, label=cc_label) + m = self.tags_view.model() if rename_func: for item in to_delete: delete_func(item) + m.delete_item_from_all_user_categories(orig_name[item], category) for old_id in to_rename: rename_func(old_id, new_name=unicode(to_rename[old_id])) + m.rename_item_in_all_user_categories(orig_name[old_id], + category, unicode(to_rename[old_id])) # Clean up the library view self.do_tag_item_renamed() - self.tags_view.set_new_model() + self.tags_view.recount() def do_tag_item_renamed(self): # Clean up library view and search From 9626ccab2b5005af52d1ba163ff587d2b09584b7 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 10:24:05 +0000 Subject: [PATCH 31/55] Fix some obscure cases: 1) cannot move items from a global search term user category 2) cannot remove items from a global search term user category 3) cannot rename global search term categories 4) ensure tags in the news category are editable 5) ensure renaming in a user category works with hierarchical items --- src/calibre/gui2/tag_view.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 01cda54afe..2c5864920e 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -299,7 +299,7 @@ class TagsView(QTreeView): # {{{ self.context_menu.addAction(_('Edit sort for %s')%tag.name, partial(self.context_menu_handler, action='edit_author_sort', index=tag.id)) - if key.startswith('@'): + if key.startswith('@') and not item.is_gst: self.context_menu.addAction(self.user_category_icon, _('Remove %s from category %s')%(tag.name, item.py_name), partial(self.context_menu_handler, @@ -317,7 +317,7 @@ class TagsView(QTreeView): # {{{ search_state=TAG_SEARCH_STATES['mark_minus'], index=index)) self.context_menu.addSeparator() - elif key.startswith('@'): + elif key.startswith('@') and not item.is_gst: if item.can_edit: self.context_menu.addAction(self.user_category_icon, _('Rename %s')%item.py_name, @@ -427,7 +427,8 @@ class TagsView(QTreeView): # {{{ data = str(event.mimeData().data('application/calibre+from_tag_browser')) src = cPickle.loads(data) for s in src: - if s[0] == TagTreeItem.TAG and not s[1].startswith('@'): + if s[0] == TagTreeItem.TAG and \ + (not s[1].startswith('@') or s[2]): return self.setDropIndicatorShown(True) return @@ -653,8 +654,10 @@ class TagsModel(QAbstractItemModel): # {{{ for i, r in enumerate(self.row_map): if self.hidden_categories and self.categories[i] in self.hidden_categories: continue + is_gst = False if r.startswith('@') and r[1:] in gst: tt = _(u'The grouped search term name is "{0}"').format(r[1:]) + is_gst = True elif r == 'news': tt = '' else: @@ -675,7 +678,8 @@ class TagsModel(QAbstractItemModel): # {{{ last_category_node = node category_node_map[path] = node self.category_nodes.append(node) - node.can_edit = i == (len(path_parts) - 1) + node.can_edit = (not is_gst) and (i == (len(path_parts)-1)) + node.is_gst = is_gst else: last_category_node = category_node_map[path] path += '.' @@ -684,6 +688,7 @@ class TagsModel(QAbstractItemModel): # {{{ data=self.categories[i], category_icon=self.category_icon_map[r], tooltip=tt, category_key=r) + node.is_gst = False category_node_map[r] = node last_category_node = node self.category_nodes.append(node) @@ -709,7 +714,7 @@ class TagsModel(QAbstractItemModel): # {{{ p = node while p.type != TagTreeItem.CATEGORY: p = p.parent - d = (node.type, p.category_key, + d = (node.type, p.category_key, p.is_gst, getattr(t, 'original_name', t.name), t.category, t.id) data.append(d) else: @@ -748,7 +753,8 @@ class TagsModel(QAbstractItemModel): # {{{ def move_or_copy_item_to_user_category(self, src, dest, action): ''' src is a list of tuples representing items to copy. The tuple is - (type, containing category key, full name, category key, id) + (type, containing category key, category key is global search term, + full name, category key, id) The type must be TagTreeItem.TAG dest is the TagTreeItem node to receive the items action is Qt.CopyAction or Qt.MoveAction @@ -757,7 +763,7 @@ class TagsModel(QAbstractItemModel): # {{{ parent_node = None copied_node = None for s in src: - src_parent, src_name, src_cat = s[1:4] + src_parent, src_parent_is_gst, src_name, src_cat = s[1:5] parent_node = src_parent if src_parent.startswith('@'): is_uc = True @@ -769,7 +775,8 @@ class TagsModel(QAbstractItemModel): # {{{ continue new_cat = [] # delete the item if the source is a user category and action is move - if is_uc and src_parent in user_cats and action == Qt.MoveAction: + if is_uc and not src_parent_is_gst and src_parent in user_cats and \ + action == Qt.MoveAction: for tup in user_cats[src_parent]: if src_name == tup[0] and src_cat == tup[1]: continue @@ -1074,9 +1081,9 @@ class TagsModel(QAbstractItemModel): # {{{ TagTreeItem(parent=node_parent, data=tag, tooltip=tt, icon_map=self.icon_state_map) self.endInsertRows() - tag.can_edit = key != 'formats' and \ + tag.can_edit = key != 'formats' and (key == 'news' or \ self.db.field_metadata[tag.category]['datatype'] in \ - ['text', 'series', 'enumeration'] + ['text', 'series', 'enumeration']) else: for i,comp in enumerate(components): child_map = dict([(t.tag.name, t) for t in node_parent.children @@ -1176,7 +1183,7 @@ class TagsModel(QAbstractItemModel): # {{{ return True key = item.tag.category - name = item.tag.name + name = getattr(item.tag, 'original_name', item.tag.name) # make certain we know about the item's category if key not in self.db.field_metadata: return False From 38a3d25562384115686ec1746d111a1e8036be1c Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 10:27:30 +0000 Subject: [PATCH 32/55] prevent dropping items on global search term user categories --- src/calibre/gui2/tag_view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 2c5864920e..c2949f777a 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -419,7 +419,7 @@ class TagsView(QTreeView): # {{{ if item.type == TagTreeItem.TAG and flags & Qt.ItemIsDropEnabled: self.setDropIndicatorShown(not src_is_tb) return - if item.type == TagTreeItem.CATEGORY: + if item.type == TagTreeItem.CATEGORY and not item.is_gst: fm_dest = self.db.metadata_for_field(item.category_key) if fm_dest['kind'] == 'user': if src_is_tb: From a8c278d44960b02ae0d74f1fa1b84949ca80ae1e Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 10:50:38 +0000 Subject: [PATCH 33/55] Fix problem with restoring the tree position when dropping hierarchical items onto user categories --- src/calibre/gui2/tag_view.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index c2949f777a..d53f510e55 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -1446,6 +1446,9 @@ class TagsModel(QAbstractItemModel): # {{{ (not equals_match and lower(name).find(txt) >= 0): self.path_found = path return True + for i,c in enumerate(tag_item.children): + if process_tag(depth+1, self.createIndex(i, 0, c), c, start_path): + return True return False def process_level(depth, category_index, start_path): From 762f6f255766144bfbafeb3ca81e0bdb670001de Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 10:56:28 +0000 Subject: [PATCH 34/55] Fix right-click removal of hierarchical items from user categories --- src/calibre/gui2/tag_view.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index d53f510e55..235251f8ea 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -236,7 +236,8 @@ class TagsView(QTreeView): # {{{ self.user_category_delete.emit(key) return if action == 'delete_item_from_user_category': - self.del_user_cat_item.emit(key, index.name, index.category) + self.del_user_cat_item.emit(key, + getattr(index, 'original_name', index.name), index.category) return if action == 'manage_searches': self.saved_search_edit.emit(category) From 9f7034f893cdab461998080f961a4aa0bbffbec5 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 11:06:48 +0000 Subject: [PATCH 35/55] Correct problem searching for hierarchical items when parent nodes exist as real items. --- src/calibre/gui2/tag_view.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 235251f8ea..39135fec27 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -1092,6 +1092,7 @@ class TagsModel(QAbstractItemModel): # {{{ if comp in child_map: node_parent = child_map[comp] node_parent.tag.count += tag.count + node_parent.tag.use_prefix = True else: if i < len(components)-1: t = copy.copy(tag) From c2a1bd7a4c2a47057ef71a7ac631ec8b8d4cbdc3 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 14:04:22 +0000 Subject: [PATCH 36/55] Add a right-click option to send (copy) an item to a user category. Clean up some names and documentation --- src/calibre/gui2/tag_view.py | 158 ++++++++++++++++++++++++++--------- 1 file changed, 118 insertions(+), 40 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 39135fec27..804fb503d1 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -70,18 +70,19 @@ TAG_SEARCH_STATES = {'clear': 0, 'mark_plus': 1, 'mark_minus': 2} class TagsView(QTreeView): # {{{ - refresh_required = pyqtSignal() - tags_marked = pyqtSignal(object) - user_category_edit = pyqtSignal(object) - user_category_delete= pyqtSignal(object) - del_user_cat_item = pyqtSignal(object, object, object) - add_subcategory = pyqtSignal(object) - tag_list_edit = pyqtSignal(object, object) - saved_search_edit = pyqtSignal(object) - author_sort_edit = pyqtSignal(object, object) - tag_item_renamed = pyqtSignal() - search_item_renamed = pyqtSignal() - drag_drop_finished = pyqtSignal(object, object) + refresh_required = pyqtSignal() + tags_marked = pyqtSignal(object) + edit_user_category = pyqtSignal(object) + delete_user_category = pyqtSignal(object) + del_item_from_user_cat = pyqtSignal(object, object, object) + add_item_to_user_cat = pyqtSignal(object, object, object) + add_subcategory = pyqtSignal(object) + tag_list_edit = pyqtSignal(object, object) + saved_search_edit = pyqtSignal(object) + author_sort_edit = pyqtSignal(object, object) + tag_item_renamed = pyqtSignal() + search_item_renamed = pyqtSignal() + drag_drop_finished = pyqtSignal(object, object) def __init__(self, parent=None): QTreeView.__init__(self, parent=None) @@ -221,11 +222,16 @@ class TagsView(QTreeView): # {{{ self.tag_list_edit.emit(category, key) return if action == 'manage_categories': - self.user_category_edit.emit(category) + self.edit_user_category.emit(category) return if action == 'search': self._toggle(index, set_to=search_state) return + if action == 'add_to_category': + self.add_item_to_user_cat.emit(category, + getattr(index, 'original_name', index.name), + index.category) + return if action == 'add_subcategory': self.add_subcategory.emit(key) return @@ -233,10 +239,10 @@ class TagsView(QTreeView): # {{{ self.tags_marked.emit(key + ':' + search_state) return if action == 'delete_user_category': - self.user_category_delete.emit(key) + self.delete_user_category.emit(key) return if action == 'delete_item_from_user_category': - self.del_user_cat_item.emit(key, + self.del_item_from_user_cat.emit(key, getattr(index, 'original_name', index.name), index.category) return if action == 'manage_searches': @@ -300,6 +306,25 @@ class TagsView(QTreeView): # {{{ self.context_menu.addAction(_('Edit sort for %s')%tag.name, partial(self.context_menu_handler, action='edit_author_sort', index=tag.id)) + m = self.context_menu.addMenu(self.user_category_icon, + _('Add %s to user category')%tag.name) + nt = self.model().category_node_tree + def add_node_tree(tree_dict, m, path): + p = path[:] + for k in sorted(tree_dict.keys(), key=sort_key): + p.append(k) + m.addAction(self.user_category_icon, k, + partial(self.context_menu_handler, + 'add_to_category', + category='.'.join(p), + index=tag)) + if len(tree_dict[k]): + tm = m.addMenu(self.user_category_icon, + _('Children of %s')%k) + add_node_tree(tree_dict[k], tm, p) + p.pop() + add_node_tree(nt, m, []) + if key.startswith('@') and not item.is_gst: self.context_menu.addAction(self.user_category_icon, _('Remove %s from category %s')%(tag.name, item.py_name), @@ -652,6 +677,7 @@ class TagsModel(QAbstractItemModel): # {{{ last_category_node = None category_node_map = {} + self.category_node_tree = {} for i, r in enumerate(self.row_map): if self.hidden_categories and self.categories[i] in self.hidden_categories: continue @@ -668,6 +694,7 @@ class TagsModel(QAbstractItemModel): # {{{ path_parts = [p.strip() for p in r.split('.') if p.strip()] path = '' last_category_node = self.root_item + tree_root = self.category_node_tree for i,p in enumerate(path_parts): path += p if path not in category_node_map: @@ -681,8 +708,12 @@ class TagsModel(QAbstractItemModel): # {{{ self.category_nodes.append(node) node.can_edit = (not is_gst) and (i == (len(path_parts)-1)) node.is_gst = is_gst + if not is_gst: + tree_root[p] = {} + tree_root = tree_root[p] else: last_category_node = category_node_map[path] + tree_root = tree_root[p] path += '.' else: node = TagTreeItem(parent=self.root_item, @@ -756,6 +787,7 @@ class TagsModel(QAbstractItemModel): # {{{ src is a list of tuples representing items to copy. The tuple is (type, containing category key, category key is global search term, full name, category key, id) + The 'id' member is ignored, and can be None. The type must be TagTreeItem.TAG dest is the TagTreeItem node to receive the items action is Qt.CopyAction or Qt.MoveAction @@ -1550,43 +1582,34 @@ class TagBrowserMixin(object): # {{{ self.tags_view.set_database(db, self.tag_match, self.sort_by) self.tags_view.tags_marked.connect(self.search.set_search_string) self.tags_view.tag_list_edit.connect(self.do_tags_list_edit) - self.tags_view.user_category_edit.connect(self.do_user_categories_edit) - self.tags_view.user_category_delete.connect(self.do_user_category_delete) - self.tags_view.del_user_cat_item.connect(self.do_del_user_cat_item) + self.tags_view.edit_user_category.connect(self.do_edit_user_categories) + self.tags_view.delete_user_category.connect(self.do_delete_user_category) + self.tags_view.del_item_from_user_cat.connect(self.do_del_item_from_user_cat) self.tags_view.add_subcategory.connect(self.do_add_subcategory) + self.tags_view.add_item_to_user_cat.connect(self.do_add_item_to_user_cat) self.tags_view.saved_search_edit.connect(self.do_saved_search_edit) self.tags_view.author_sort_edit.connect(self.do_author_sort_edit) self.tags_view.tag_item_renamed.connect(self.do_tag_item_renamed) self.tags_view.search_item_renamed.connect(self.saved_searches_changed) self.tags_view.drag_drop_finished.connect(self.drag_drop_finished) self.edit_categories.clicked.connect(lambda x: - self.do_user_categories_edit()) + self.do_edit_user_categories()) - def do_del_user_cat_item(self, user_cat, item_name, item_category): + def do_add_subcategory(self, on_category_key, new_category_name=None): ''' - Delete the item (item_name, item_category) from the user category with - key user_cat. Any leading '@' characters are removed + Add a subcategory to the category 'on_category'. If new_category_name is + None, then a default name is shown and the user is offered the + opportunity to edit the name. ''' - if user_cat.startswith('@'): - user_cat = user_cat[1:] - db = self.library_view.model().db - user_cats = db.prefs.get('user_categories', {}) - if user_cat not in user_cats: - error_dialog(self.tags_view, _('Remove category'), - _('User category %s does not exist')%user_cat, - show=True) - return - self.tags_view.model().delete_item_from_user_category(user_cat, - item_name, item_category) - self.tags_view.recount() - - def do_add_subcategory(self, on_category_key=None): db = self.library_view.model().db user_cats = db.prefs.get('user_categories', {}) # Ensure that the temporary name we will use is not already there i = 0 - new_name = _('New Category').replace('.', '') + if new_category_name is not None: + new_name = new_category_name.replace('.', '') + else: + new_name = _('New Category').replace('.', '') n = new_name while True: new_cat = on_category_key[1:] + '.' + n @@ -1602,9 +1625,13 @@ class TagBrowserMixin(object): # {{{ idx = m.index_for_path(m.find_category_node('@' + new_cat)) m.show_item_at_index(idx) # Open the editor on the new item to rename it - self.tags_view.edit(idx) + if new_category_name is None: + self.tags_view.edit(idx) - def do_user_categories_edit(self, on_category=None): + def do_edit_user_categories(self, on_category=None): + ''' + Open the user categories editor. + ''' db = self.library_view.model().db d = TagCategories(self, db, on_category) if d.exec_() == d.Accepted: @@ -1615,7 +1642,7 @@ class TagBrowserMixin(object): # {{{ db.data.change_search_locations(db.field_metadata.get_search_terms()) self.tags_view.set_new_model() - def do_user_category_delete(self, category_name): + def do_delete_user_category(self, category_name): ''' Delete the user category named category_name. Any leading '@' is removed ''' @@ -1648,7 +1675,55 @@ class TagBrowserMixin(object): # {{{ db.prefs.set('user_categories', user_cats) self.tags_view.set_new_model() + def do_del_item_from_user_cat(self, user_cat, item_name, item_category): + ''' + Delete the item (item_name, item_category) from the user category with + key user_cat. Any leading '@' characters are removed + ''' + if user_cat.startswith('@'): + user_cat = user_cat[1:] + db = self.library_view.model().db + user_cats = db.prefs.get('user_categories', {}) + if user_cat not in user_cats: + error_dialog(self.tags_view, _('Remove category'), + _('User category %s does not exist')%user_cat, + show=True) + return + self.tags_view.model().delete_item_from_user_category(user_cat, + item_name, item_category) + self.tags_view.recount() + + def do_add_item_to_user_cat(self, dest_category, src_name, src_category): + ''' + Add the item src_name in src_category to the user category + dest_category. Any leading '@' is removed + ''' + db = self.library_view.model().db + user_cats = db.prefs.get('user_categories', {}) + + if dest_category.startswith('@'): + dest_category = dest_category[1:] + if dest_category not in user_cats: + return error_dialog(self.tags_view, _('Add to user category'), + _('A user category %s does not exist')%dest_category, show=True) + + # Now add the item to the destination user category + add_it = True + if src_category == 'news': + src_category = 'tags' + for tup in user_cats[dest_category]: + if src_name == tup[0] and src_category == tup[1]: + add_it = False + if add_it: + user_cats[dest_category].append([src_name, src_category, 0]) + db.prefs.set('user_categories', user_cats) + self.tags_view.recount() + def do_tags_list_edit(self, tag, category): + ''' + Open the 'manage_X' dialog where X == category. If tag is not None, the + dialog will position the editor on that item. + ''' db=self.library_view.model().db if category == 'tags': result = db.get_tags_with_ids() @@ -1716,6 +1791,9 @@ class TagBrowserMixin(object): # {{{ # refreshing the tags view happens at the emit()/call() site def do_author_sort_edit(self, parent, id): + ''' + Open the manage authors dialog + ''' db = self.library_view.model().db editor = EditAuthorsDialog(parent, db, id) d = editor.exec_() From 0ea93c7f4e8b2be966db204013f036e4b396439e Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 24 Feb 2011 07:32:48 -0700 Subject: [PATCH 37/55] Updated Adevarul and gsp.ro --- resources/recipes/adevarul.recipe | 19 +++++++++--- resources/recipes/gsp.recipe | 51 ++++++++++++++++++++++--------- resources/template-functions.json | 1 + 3 files changed, 52 insertions(+), 19 deletions(-) diff --git a/resources/recipes/adevarul.recipe b/resources/recipes/adevarul.recipe index ea0f2826ce..eec3ca771a 100644 --- a/resources/recipes/adevarul.recipe +++ b/resources/recipes/adevarul.recipe @@ -32,16 +32,25 @@ class Adevarul(BasicNewsRecipe): } keep_only_tags = [ dict(name='div', attrs={'class':'article_header'}) - ,dict(name='div', attrs={'class':'bd'}) + ,dict(name='div', attrs={'class':'bb-tu first-t bb-article-body'}) ] - remove_tags = [ dict(name='div', attrs={'class':'bb-wg-article_related_attachements'}) + remove_tags = [ + dict(name='li', attrs={'class':'author'}) + ,dict(name='li', attrs={'class':'date'}) + ,dict(name='li', attrs={'class':'comments'}) + ,dict(name='div', attrs={'class':'bb-wg-article_related_attachements'}) ,dict(name='div', attrs={'class':'bb-md bb-md-article_comments'}) - ,dict(name='form', attrs={'id':'bb-comment-create-form'}) - ] + ,dict(name='form', attrs={'id':'bb-comment-create-form'}) + ,dict(name='div', attrs={'id':'mediatag'}) + ,dict(name='div', attrs={'id':'ft'}) + ,dict(name='div', attrs={'id':'comment_wrapper'}) + ] - remove_tags_after = [ dict(name='form', attrs={'id':'bb-comment-create-form'}) ] + remove_tags_after = [ + dict(name='div', attrs={'id':'comment_wrapper'}), + ] feeds = [ (u'\u0218tiri', u'http://www.adevarul.ro/rss/latest') ] diff --git a/resources/recipes/gsp.recipe b/resources/recipes/gsp.recipe index 90a8eecfe6..efc76ee71e 100644 --- a/resources/recipes/gsp.recipe +++ b/resources/recipes/gsp.recipe @@ -1,20 +1,43 @@ +# -*- coding: utf-8 -*- +#!/usr/bin/env python + +__license__ = 'GPL v3' +__copyright__ = u'2011, Silviu Cotoar\u0103' +''' +gsp.ro +''' + from calibre.web.feeds.news import BasicNewsRecipe -class AdvancedUserRecipe1286351181(BasicNewsRecipe): - title = u'gsp.ro' - __author__ = 'bucsie' - oldest_article = 2 +class GSP(BasicNewsRecipe): + title = u'Gazeta Sporturilor' + language = 'ro' + __author__ = u'Silviu Cotoar\u0103' + description = u'Gazeta Sporturilor' + publisher = u'Gazeta Sporturilor' + category = 'Ziare,Sport,Stiri,Romania' + oldest_article = 5 max_articles_per_feed = 100 - language='ro' - cover_url ='http://www.gsp.ro/images/sigla_rosu.jpg' + no_stylesheets = True + use_embedded_content = False + encoding = 'utf-8' + remove_javascript = True + cover_url = 'http://www.gsp.ro/images/logo.jpg' - remove_tags = [ - dict(name='div', attrs={'class':['related_articles', 'articol_noteaza straight_line dotted_line_top', 'comentarii','mai_multe_articole']}), - dict(name='div', attrs={'id':'icons'}) - ] - remove_tags_after = dict(name='div', attrs={'id':'adoceanintactrovccmgpmnyt'}) + conversion_options = { + 'comments' : description + ,'tags' : category + ,'language' : language + ,'publisher' : publisher + } - feeds = [(u'toate stirile', u'http://www.gsp.ro/index.php?section=section&screen=rss')] + keep_only_tags = [ dict(name='h1', attrs={'class':'serif title_2'}) + ,dict(name='div', attrs={'id':'only_text'}) + ,dict(name='span', attrs={'class':'block poza_principala'}) + ] + + feeds = [ (u'\u0218tiri', u'http://www.gsp.ro/rss.xml') ] + + def preprocess_html(self, soup): + return self.adeify_images(soup) - def print_version(self, url): - return 'http://www1.gsp.ro/print/' + url[(url.rindex('/')+1):] diff --git a/resources/template-functions.json b/resources/template-functions.json index 332ce1ddea..5d9b6a11a3 100644 --- a/resources/template-functions.json +++ b/resources/template-functions.json @@ -15,6 +15,7 @@ "template": "def evaluate(self, formatter, kwargs, mi, locals, template):\n template = template.replace('[[', '{').replace(']]', '}')\n return formatter.__class__().safe_format(template, kwargs, 'TEMPLATE', mi)\n", "print": "def evaluate(self, formatter, kwargs, mi, locals, *args):\n print args\n return None\n", "titlecase": "def evaluate(self, formatter, kwargs, mi, locals, val):\n return titlecase(val)\n", + "sublist": "def evaluate(self, formatter, kwargs, mi, locals, val, start_index, end_index, sep):\n if not val:\n return ''\n si = int(start_index)\n ei = int(end_index)\n val = val.split(sep)\n try:\n if ei == 0:\n return sep.join(val[si:])\n else:\n return sep.join(val[si:ei])\n except:\n return ''\n", "test": "def evaluate(self, formatter, kwargs, mi, locals, val, value_if_set, value_not_set):\n if val:\n return value_if_set\n else:\n return value_not_set\n", "eval": "def evaluate(self, formatter, kwargs, mi, locals, template):\n from formatter import eval_formatter\n template = template.replace('[[', '{').replace(']]', '}')\n return eval_formatter.safe_format(template, locals, 'EVAL', None)\n", "multiply": "def evaluate(self, formatter, kwargs, mi, locals, x, y):\n x = float(x if x else 0)\n y = float(y if y else 0)\n return unicode(x * y)\n", From 68a798eba11b55477c79ef4d0a9a4bc70a44b19e Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 14:42:40 +0000 Subject: [PATCH 38/55] Remove '@' from send to user category context menu --- src/calibre/gui2/tag_view.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 804fb503d1..bb39ef3e29 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -313,14 +313,15 @@ class TagsView(QTreeView): # {{{ p = path[:] for k in sorted(tree_dict.keys(), key=sort_key): p.append(k) - m.addAction(self.user_category_icon, k, + n = k[1:] if k.startswith('@') else k + m.addAction(self.user_category_icon, n, partial(self.context_menu_handler, 'add_to_category', category='.'.join(p), index=tag)) if len(tree_dict[k]): tm = m.addMenu(self.user_category_icon, - _('Children of %s')%k) + _('Children of %s')%n) add_node_tree(tree_dict[k], tm, p) p.pop() add_node_tree(nt, m, []) From 7b7ac2f91eae0ec7a4c37a535f038982232a920b Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 14:49:44 +0000 Subject: [PATCH 39/55] Fix not focusing the manage X box on a hierarchical sub-item --- src/calibre/gui2/tag_view.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index bb39ef3e29..06f01a1649 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -386,7 +386,8 @@ class TagsView(QTreeView): # {{{ self.db.field_metadata[key]['is_custom']: self.context_menu.addAction(_('Manage %s')%category, partial(self.context_menu_handler, action='open_editor', - category=tag.name if tag else None, key=key)) + category=getattr(tag, 'original_name', tag.name) + if tag else None, key=key)) elif key == 'authors': self.context_menu.addAction(_('Manage %s')%category, partial(self.context_menu_handler, action='edit_author_sort')) From df46544ee8fde3718933cb59c19ce2634246ac29 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Thu, 24 Feb 2011 15:02:19 +0000 Subject: [PATCH 40/55] Fix searching for prefixes where the prefix exists on a non-matching item --- src/calibre/library/caches.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index 4f5a034222..e626d446d2 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -128,7 +128,8 @@ def _match(query, value, matchkind): if query[0] == '.': if t.startswith(query[1:]): ql = len(query) - 1 - return (len(t) == ql) or (t[ql:ql+1] == '.') + if (len(t) == ql) or (t[ql:ql+1] == '.'): + return True elif query == t: return True elif ((matchkind == REGEXP_MATCH and re.search(query, t, re.I)) or ### search unanchored From 19cc3a29a6bfb13151c55067fc1a5f3eca6a9825 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 24 Feb 2011 08:03:08 -0700 Subject: [PATCH 41/55] Fix #9138 (New York Times Headlines Recipe no longer works) --- resources/recipes/nytimes.recipe | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/recipes/nytimes.recipe b/resources/recipes/nytimes.recipe index b2043bb463..0a5c310af4 100644 --- a/resources/recipes/nytimes.recipe +++ b/resources/recipes/nytimes.recipe @@ -89,7 +89,7 @@ class NYTimes(BasicNewsRecipe): if headlinesOnly: title='New York Times Headlines' description = 'Headlines from the New York Times. Needs a subscription from http://www.nytimes.com' - needs_subscription = True + needs_subscription = 'optional' elif webEdition: title='New York Times (Web)' description = 'New York Times on the Web' From 50ad3d91364c3c802fb917619415b7e0910784c2 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 24 Feb 2011 08:33:06 -0700 Subject: [PATCH 42/55] Fix ESPN soccernet feed --- resources/recipes/espn.recipe | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/resources/recipes/espn.recipe b/resources/recipes/espn.recipe index 178dbf27a8..34c772f767 100644 --- a/resources/recipes/espn.recipe +++ b/resources/recipes/espn.recipe @@ -41,7 +41,8 @@ class ESPN(BasicNewsRecipe): ''' - feeds = [('Top Headlines', 'http://sports.espn.go.com/espn/rss/news'), + feeds = [ + ('Top Headlines', 'http://sports.espn.go.com/espn/rss/news'), 'http://sports.espn.go.com/espn/rss/nfl/news', 'http://sports.espn.go.com/espn/rss/nba/news', 'http://sports.espn.go.com/espn/rss/mlb/news', @@ -107,10 +108,11 @@ class ESPN(BasicNewsRecipe): if match and 'soccernet' not in url and 'bassmaster' not in url: return 'http://sports.espn.go.com/espn/print?'+match.group(1)+'&type=story' else: - if match and 'soccernet' in url: - splitlist = url.split("&", 5) - newurl = 'http://soccernet.espn.go.com/print?'+match.group(1)+'&type=story' + '&' + str(splitlist[2] ) - return newurl + if 'soccernet' in url: + match = re.search(r'/id/(\d+)/', url) + if match: + return \ + 'http://soccernet.espn.go.com/print?id=%s&type=story' % match.group(1) #else: # if 'bassmaster' in url: # return url From 2101dcf2b5e0925b42c8af00ddc64243a021e042 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 24 Feb 2011 09:18:10 -0700 Subject: [PATCH 43/55] EPUB Output: Remove unnecessary CSS page breaks as they confuse the latest release of iBooks --- resources/templates/html.css | 5 ----- src/calibre/ebooks/conversion/plumber.py | 4 +++- src/calibre/ebooks/lit/output.py | 3 ++- src/calibre/ebooks/oeb/transforms/flatcss.py | 5 ++++- src/calibre/ebooks/oeb/transforms/split.py | 7 ++++++- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/resources/templates/html.css b/resources/templates/html.css index e9b683ca34..79c80583bf 100644 --- a/resources/templates/html.css +++ b/resources/templates/html.css @@ -391,11 +391,6 @@ noembed, param, link { display: none; } -/* Page breaks at body tags, to help out with LIT-generation */ -body { - page-break-before: always; -} - /* Explicit line-breaks are blocks, sure... */ br { display: block; diff --git a/src/calibre/ebooks/conversion/plumber.py b/src/calibre/ebooks/conversion/plumber.py index 1d263eb762..9a0c3f3c7f 100644 --- a/src/calibre/ebooks/conversion/plumber.py +++ b/src/calibre/ebooks/conversion/plumber.py @@ -984,7 +984,9 @@ OptionRecommendation(name='sr3_replace', flattener = CSSFlattener(fbase=fbase, fkey=fkey, lineh=line_height, untable=self.output_plugin.file_type in ('mobi','lit'), - unfloat=self.output_plugin.file_type in ('mobi', 'lit')) + unfloat=self.output_plugin.file_type in ('mobi', 'lit'), + page_break_on_body=self.output_plugin.file_type in ('mobi', + 'lit')) flattener(self.oeb, self.opts) self.opts.insert_blank_line = oibl self.opts.remove_paragraph_spacing = orps diff --git a/src/calibre/ebooks/lit/output.py b/src/calibre/ebooks/lit/output.py index 423fb9ce7c..0b07bc7705 100644 --- a/src/calibre/ebooks/lit/output.py +++ b/src/calibre/ebooks/lit/output.py @@ -22,7 +22,8 @@ class LITOutput(OutputFormatPlugin): from calibre.ebooks.oeb.transforms.htmltoc import HTMLTOCAdder from calibre.ebooks.lit.writer import LitWriter from calibre.ebooks.oeb.transforms.split import Split - split = Split(split_on_page_breaks=True, max_flow_size=0) + split = Split(split_on_page_breaks=True, max_flow_size=0, + remove_css_pagebreaks=False) split(self.oeb, self.opts) diff --git a/src/calibre/ebooks/oeb/transforms/flatcss.py b/src/calibre/ebooks/oeb/transforms/flatcss.py index db6bdf0a7a..368f5eb289 100644 --- a/src/calibre/ebooks/oeb/transforms/flatcss.py +++ b/src/calibre/ebooks/oeb/transforms/flatcss.py @@ -100,12 +100,13 @@ def FontMapper(sbase=None, dbase=None, dkey=None): class CSSFlattener(object): def __init__(self, fbase=None, fkey=None, lineh=None, unfloat=False, - untable=False): + untable=False, page_break_on_body=False): self.fbase = fbase self.fkey = fkey self.lineh = lineh self.unfloat = unfloat self.untable = untable + self.page_break_on_body = page_break_on_body @classmethod def config(cls, cfg): @@ -139,6 +140,8 @@ class CSSFlattener(object): bs.append('margin-right : %fpt'%\ float(self.context.margin_right)) bs.extend(['padding-left: 0pt', 'padding-right: 0pt']) + if self.page_break_on_body: + bs.extend(['page-break-before: always']) if self.context.change_justification != 'original': bs.append('text-align: '+ self.context.change_justification) body.set('style', '; '.join(bs)) diff --git a/src/calibre/ebooks/oeb/transforms/split.py b/src/calibre/ebooks/oeb/transforms/split.py index 4633131dc0..69de740ddc 100644 --- a/src/calibre/ebooks/oeb/transforms/split.py +++ b/src/calibre/ebooks/oeb/transforms/split.py @@ -38,11 +38,12 @@ class SplitError(ValueError): class Split(object): def __init__(self, split_on_page_breaks=True, page_breaks_xpath=None, - max_flow_size=0): + max_flow_size=0, remove_css_pagebreaks=True): self.split_on_page_breaks = split_on_page_breaks self.page_breaks_xpath = page_breaks_xpath self.max_flow_size = max_flow_size self.page_break_selectors = None + self.remove_css_pagebreaks = remove_css_pagebreaks if self.page_breaks_xpath is not None: self.page_break_selectors = [(XPath(self.page_breaks_xpath), False)] @@ -83,12 +84,16 @@ class Split(object): if before and before != 'avoid': self.page_break_selectors.add((CSSSelector(rule.selectorText), True)) + if self.remove_css_pagebreaks: + rule.style.removeProperty('page-break-before') except: pass try: if after and after != 'avoid': self.page_break_selectors.add((CSSSelector(rule.selectorText), False)) + if self.remove_css_pagebreaks: + rule.style.removeProperty('page-break-after') except: pass page_breaks = set([]) From 019c17973e1907c2e50cbf3ab67e73f008c279e0 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 24 Feb 2011 09:21:51 -0700 Subject: [PATCH 44/55] ODT Input: Do not force the background color to white. Fixes #9118 (White background color from OpenOffice to Mobi added) --- src/odf/odf2xhtml.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/odf/odf2xhtml.py b/src/odf/odf2xhtml.py index 6e3e753ebb..53a3e87dc2 100644 --- a/src/odf/odf2xhtml.py +++ b/src/odf/odf2xhtml.py @@ -659,7 +659,8 @@ class ODF2XHTML(handler.ContentHandler): self.opentag('style', {'type':"text/css"}, True) self.writeout('/* Date: Thu, 24 Feb 2011 09:26:10 -0700 Subject: [PATCH 45/55] ... --- src/calibre/web/feeds/templates.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/calibre/web/feeds/templates.py b/src/calibre/web/feeds/templates.py index eefd897614..225a78be5c 100644 --- a/src/calibre/web/feeds/templates.py +++ b/src/calibre/web/feeds/templates.py @@ -136,7 +136,7 @@ class FeedTemplate(Template): head.append(STYLE(style, type='text/css')) if extra_css: head.append(STYLE(extra_css, type='text/css')) - body = BODY(style='page-break-before:always') + body = BODY() body.append(self.get_navbar(f, feeds)) div = DIV( @@ -322,7 +322,7 @@ class TouchscreenFeedTemplate(Template): head.append(STYLE(style, type='text/css')) if extra_css: head.append(STYLE(extra_css, type='text/css')) - body = BODY(style='page-break-before:always') + body = BODY() div = DIV( top_navbar, H2(feed.title, CLASS('feed_title')) From 07d1ca7ec3669ecaa2d202819be3659c5dada94b Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 24 Feb 2011 10:38:26 -0700 Subject: [PATCH 46/55] ... --- src/calibre/ebooks/mobi/writer.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/calibre/ebooks/mobi/writer.py b/src/calibre/ebooks/mobi/writer.py index 0c33dffef2..2be699e525 100644 --- a/src/calibre/ebooks/mobi/writer.py +++ b/src/calibre/ebooks/mobi/writer.py @@ -2256,22 +2256,22 @@ class MobiWriter(object): return sectionIndices, sectionParents def _generate_section_article_indices(self, i, section, entries, sectionIndices, sectionParents): - sectionArticles = list(section.iter())[1:] - # Iterate over the section's articles + sectionArticles = list(section.iter())[1:] + # Iterate over the section's articles - for (j, article) in enumerate(sectionArticles): - # Recompute offset and length for each article - offset, length = self._compute_offset_length(i, article, entries) - if self.opts.verbose > 2 : - self._oeb.logger.info( "article %02d: offset = 0x%06X length = 0x%06X" % (j, offset, length) ) + for (j, article) in enumerate(sectionArticles): + # Recompute offset and length for each article + offset, length = self._compute_offset_length(i, article, entries) + if self.opts.verbose > 2 : + self._oeb.logger.info( "article %02d: offset = 0x%06X length = 0x%06X" % (j, offset, length) ) - ctoc_map_index = i + j + 1 + ctoc_map_index = i + j + 1 - #hasAuthor = self._ctoc_map[ctoc_map_index].get('authorOffset') - #hasDescription = self._ctoc_map[ctoc_map_index].get('descriptionOffset') - mySectionParent = sectionParents[sectionIndices[i-1]] - myNewArticle = MobiArticle(mySectionParent, offset, length, ctoc_map_index ) - mySectionParent.addArticle( myNewArticle ) + #hasAuthor = self._ctoc_map[ctoc_map_index].get('authorOffset') + #hasDescription = self._ctoc_map[ctoc_map_index].get('descriptionOffset') + mySectionParent = sectionParents[sectionIndices[i-1]] + myNewArticle = MobiArticle(mySectionParent, offset, length, ctoc_map_index ) + mySectionParent.addArticle( myNewArticle ) def _add_book_chapters(self, myDoc, indxt, indices): chapterCount = myDoc.documentStructure.chapterCount() From 02562da2a96226ee23d36b2c0793753cb4ad85c4 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 24 Feb 2011 10:43:22 -0700 Subject: [PATCH 47/55] ODT input: Update odfpy library to latest version, adds support for bookmarks --- src/odf/attrconverters.py | 76 ++++-- src/odf/element.py | 84 +++++-- src/odf/grammar.py | 2 +- src/odf/load.py | 4 +- src/odf/namespaces.py | 17 +- src/odf/odf2xhtml.py | 469 ++++++++++++++++++++++++++++++-------- src/odf/opendocument.py | 99 +++++--- 7 files changed, 576 insertions(+), 175 deletions(-) diff --git a/src/odf/attrconverters.py b/src/odf/attrconverters.py index 0117324bba..b75f80a2dd 100644 --- a/src/odf/attrconverters.py +++ b/src/odf/attrconverters.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2006-2008 Søren Roug, European Environment Agency +# Copyright (C) 2006-2010 Søren Roug, European Environment Agency # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -40,6 +40,9 @@ def cnv_boolean(attribute, arg, element): # Potentially accept color values def cnv_color(attribute, arg, element): + """ A RGB color in conformance with §5.9.11 of [XSL], that is a RGB color in notation “#rrggbb”, where + rr, gg and bb are 8-bit hexadecimal digits. + """ return str(arg) def cnv_configtype(attribute, arg, element): @@ -55,9 +58,15 @@ def cnv_data_source_has_labels(attribute, arg, element): # Understand different date formats def cnv_date(attribute, arg, element): + """ A dateOrDateTime value is either an [xmlschema-2] date value or an [xmlschema-2] dateTime + value. + """ return str(arg) def cnv_dateTime(attribute, arg, element): + """ A dateOrDateTime value is either an [xmlschema-2] date value or an [xmlschema-2] dateTime + value. + """ return str(arg) def cnv_double(attribute, arg, element): @@ -67,11 +76,31 @@ def cnv_duration(attribute, arg, element): return str(arg) def cnv_family(attribute, arg, element): + """ A style family """ if str(arg) not in ("text", "paragraph", "section", "ruby", "table", "table-column", "table-row", "table-cell", "graphic", "presentation", "drawing-page", "chart"): raise ValueError, "'%s' not allowed" % str(arg) return str(arg) +def __save_prefix(attribute, arg, element): + prefix = arg.split(':',1)[0] + if prefix == arg: + return unicode(arg) + namespace = element.get_knownns(prefix) + if namespace is None: + #raise ValueError, "'%s' is an unknown prefix" % str(prefix) + return unicode(arg) + p = element.get_nsprefix(namespace) + return unicode(arg) + +def cnv_formula(attribute, arg, element): + """ A string containing a formula. Formulas do not have a predefined syntax, but the string should + begin with a namespace prefix, followed by a “:” (COLON, U+003A) separator, followed by the text + of the formula. The namespace bound to the prefix determines the syntax and semantics of the + formula. + """ + return __save_prefix(attribute, arg, element) + def cnv_ID(attribute, arg, element): return str(arg) @@ -89,6 +118,9 @@ def cnv_legend_position(attribute, arg, element): pattern_length = re.compile(r'-?([0-9]+(\.[0-9]*)?|\.[0-9]+)((cm)|(mm)|(in)|(pt)|(pc)|(px))') def cnv_length(attribute, arg, element): + """ A (positive or negative) physical length, consisting of magnitude and unit, in conformance with the + Units of Measure defined in §5.9.13 of [XSL]. + """ global pattern_length if not pattern_length.match(arg): raise ValueError, "'%s' is not a valid length" % arg @@ -120,12 +152,12 @@ def cnv_namespacedToken(attribute, arg, element): if not pattern_namespacedToken.match(arg): raise ValueError, "'%s' is not a valid namespaced token" % arg - return arg + return __save_prefix(attribute, arg, element) -# Must accept string as argument -# NCName is defined in http://www.w3.org/TR/REC-xml-names/#NT-NCName -# Essentially an XML name minus ':' def cnv_NCName(attribute, arg, element): + """ NCName is defined in http://www.w3.org/TR/REC-xml-names/#NT-NCName + Essentially an XML name minus ':' + """ if type(arg) in types.StringTypes: return make_NCName(arg) else: @@ -226,6 +258,7 @@ attrconverters = { ((ANIMNS,u'name'), None): cnv_string, ((ANIMNS,u'sub-item'), None): cnv_string, ((ANIMNS,u'value'), None): cnv_string, +# ((DBNS,u'type'), None): cnv_namespacedToken, ((CHARTNS,u'attached-axis'), None): cnv_string, ((CHARTNS,u'class'), (CHARTNS,u'grid')): cnv_major_minor, ((CHARTNS,u'class'), None): cnv_namespacedToken, @@ -288,7 +321,7 @@ attrconverters = { ((CHARTNS,u'values-cell-range-address'), None): cnv_string, ((CHARTNS,u'vertical'), None): cnv_boolean, ((CHARTNS,u'visible'), None): cnv_boolean, - ((CONFIGNS,u'name'), None): cnv_string, + ((CONFIGNS,u'name'), None): cnv_formula, ((CONFIGNS,u'type'), None): cnv_configtype, ((DR3DNS,u'ambient-color'), None): cnv_string, ((DR3DNS,u'back-scale'), None): cnv_string, @@ -369,11 +402,11 @@ attrconverters = { ((DRAWNS,u'decimal-places'), None): cnv_string, ((DRAWNS,u'display'), None): cnv_string, ((DRAWNS,u'display-name'), None): cnv_string, - ((DRAWNS,u'distance'), None): cnv_string, + ((DRAWNS,u'distance'), None): cnv_lengthorpercent, ((DRAWNS,u'dots1'), None): cnv_integer, - ((DRAWNS,u'dots1-length'), None): cnv_length, + ((DRAWNS,u'dots1-length'), None): cnv_lengthorpercent, ((DRAWNS,u'dots2'), None): cnv_integer, - ((DRAWNS,u'dots2-length'), None): cnv_length, + ((DRAWNS,u'dots2-length'), None): cnv_lengthorpercent, ((DRAWNS,u'end-angle'), None): cnv_double, ((DRAWNS,u'end'), None): cnv_string, ((DRAWNS,u'end-color'), None): cnv_string, @@ -383,7 +416,7 @@ attrconverters = { ((DRAWNS,u'end-line-spacing-horizontal'), None): cnv_string, ((DRAWNS,u'end-line-spacing-vertical'), None): cnv_string, ((DRAWNS,u'end-shape'), None): cnv_IDREF, - ((DRAWNS,u'engine'), None): cnv_string, + ((DRAWNS,u'engine'), None): cnv_namespacedToken, ((DRAWNS,u'enhanced-path'), None): cnv_string, ((DRAWNS,u'escape-direction'), None): cnv_string, ((DRAWNS,u'extrusion-allowed'), None): cnv_boolean, @@ -604,7 +637,7 @@ attrconverters = { ((FORMNS,u'button-type'), None): cnv_string, ((FORMNS,u'command'), None): cnv_string, ((FORMNS,u'command-type'), None): cnv_string, - ((FORMNS,u'control-implementation'), None): cnv_string, + ((FORMNS,u'control-implementation'), None): cnv_namespacedToken, ((FORMNS,u'convert-empty-to-null'), None): cnv_boolean, ((FORMNS,u'current-selected'), None): cnv_boolean, ((FORMNS,u'current-state'), None): cnv_string, @@ -800,8 +833,8 @@ attrconverters = { ((PRESENTATIONNS,u'user-transformed'), None): cnv_boolean, ((PRESENTATIONNS,u'verb'), None): cnv_nonNegativeInteger, ((PRESENTATIONNS,u'visibility'), None): cnv_string, - ((SCRIPTNS,u'event-name'), None): cnv_string, - ((SCRIPTNS,u'language'), None): cnv_string, + ((SCRIPTNS,u'event-name'), None): cnv_formula, + ((SCRIPTNS,u'language'), None): cnv_formula, ((SCRIPTNS,u'macro-name'), None): cnv_string, ((SMILNS,u'accelerate'), None): cnv_double, ((SMILNS,u'accumulate'), None): cnv_string, @@ -1087,7 +1120,7 @@ attrconverters = { ((SVGNS,u'y2'), None): cnv_lengthorpercent, ((TABLENS,u'acceptance-state'), None): cnv_string, ((TABLENS,u'add-empty-lines'), None): cnv_boolean, - ((TABLENS,u'algorithm'), None): cnv_string, + ((TABLENS,u'algorithm'), None): cnv_formula, ((TABLENS,u'align'), None): cnv_string, ((TABLENS,u'allow-empty-cell'), None): cnv_boolean, ((TABLENS,u'application-data'), None): cnv_string, @@ -1106,7 +1139,7 @@ attrconverters = { ((TABLENS,u'cell-range'), None): cnv_string, ((TABLENS,u'column'), None): cnv_integer, ((TABLENS,u'comment'), None): cnv_string, - ((TABLENS,u'condition'), None): cnv_string, + ((TABLENS,u'condition'), None): cnv_formula, ((TABLENS,u'condition-source'), None): cnv_string, ((TABLENS,u'condition-source-range-address'), None): cnv_string, ((TABLENS,u'contains-error'), None): cnv_boolean, @@ -1144,13 +1177,13 @@ attrconverters = { ((TABLENS,u'end-x'), None): cnv_length, ((TABLENS,u'end-y'), None): cnv_length, ((TABLENS,u'execute'), None): cnv_boolean, - ((TABLENS,u'expression'), None): cnv_string, + ((TABLENS,u'expression'), None): cnv_formula, ((TABLENS,u'field-name'), None): cnv_string, ((TABLENS,u'field-number'), None): cnv_nonNegativeInteger, ((TABLENS,u'field-number'), None): cnv_string, ((TABLENS,u'filter-name'), None): cnv_string, ((TABLENS,u'filter-options'), None): cnv_string, - ((TABLENS,u'formula'), None): cnv_string, + ((TABLENS,u'formula'), None): cnv_formula, ((TABLENS,u'function'), None): cnv_string, ((TABLENS,u'function'), None): cnv_string, ((TABLENS,u'grand-total'), None): cnv_string, @@ -1290,7 +1323,7 @@ attrconverters = { ((TEXTNS,u'combine-entries-with-pp'), None): cnv_boolean, ((TEXTNS,u'comma-separated'), None): cnv_boolean, ((TEXTNS,u'cond-style-name'), None): cnv_StyleNameRef, - ((TEXTNS,u'condition'), None): cnv_string, + ((TEXTNS,u'condition'), None): cnv_formula, ((TEXTNS,u'connection-name'), None): cnv_string, ((TEXTNS,u'consecutive-numbering'), None): cnv_boolean, ((TEXTNS,u'continue-numbering'), None): cnv_boolean, @@ -1321,7 +1354,7 @@ attrconverters = { ((TEXTNS,u'first-row-start-column'), None): cnv_string, ((TEXTNS,u'fixed'), None): cnv_boolean, ((TEXTNS,u'footnotes-position'), None): cnv_string, - ((TEXTNS,u'formula'), None): cnv_string, + ((TEXTNS,u'formula'), None): cnv_formula, ((TEXTNS,u'global'), None): cnv_boolean, ((TEXTNS,u'howpublished'), None): cnv_string, ((TEXTNS,u'id'), None): cnv_ID, @@ -1437,7 +1470,10 @@ attrconverters = { class AttrConverters: def convert(self, attribute, value, element): - conversion = attrconverters.get((attribute,element), None) + """ Based on the element, figures out how to check/convert the attribute value + All values are converted to string + """ + conversion = attrconverters.get((attribute, element.qname), None) if conversion is not None: return conversion(attribute, value, element) else: diff --git a/src/odf/element.py b/src/odf/element.py index f0938ba53e..aad698045e 100644 --- a/src/odf/element.py +++ b/src/odf/element.py @@ -1,6 +1,6 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -# Copyright (C) 2007-2008 Søren Roug, European Environment Agency +# Copyright (C) 2007-2010 Søren Roug, European Environment Agency # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -112,6 +112,9 @@ class Node(xml.dom.Node): return self.childNodes[-1] def insertBefore(self, newChild, refChild): + """ Inserts the node newChild before the existing child node refChild. + If refChild is null, insert newChild at the end of the list of children. + """ if newChild.nodeType not in self._child_node_types: raise IllegalChild, "%s cannot be child of %s" % (newChild.tagName, self.tagName) if newChild.parentNode is not None: @@ -135,21 +138,26 @@ class Node(xml.dom.Node): newChild.parentNode = self return newChild - def appendChild(self, node): - if node.nodeType == self.DOCUMENT_FRAGMENT_NODE: - for c in tuple(node.childNodes): + def appendChild(self, newChild): + """ Adds the node newChild to the end of the list of children of this node. + If the newChild is already in the tree, it is first removed. + """ + if newChild.nodeType == self.DOCUMENT_FRAGMENT_NODE: + for c in tuple(newChild.childNodes): self.appendChild(c) ### The DOM does not clearly specify what to return in this case - return node - if node.nodeType not in self._child_node_types: - raise IllegalChild, "<%s> is not allowed in %s" % ( node.tagName, self.tagName) - if node.parentNode is not None: - node.parentNode.removeChild(node) - _append_child(self, node) - node.nextSibling = None - return node + return newChild + if newChild.nodeType not in self._child_node_types: + raise IllegalChild, "<%s> is not allowed in %s" % ( newChild.tagName, self.tagName) + if newChild.parentNode is not None: + newChild.parentNode.removeChild(newChild) + _append_child(self, newChild) + newChild.nextSibling = None + return newChild def removeChild(self, oldChild): + """ Removes the child node indicated by oldChild from the list of children, and returns it. + """ #FIXME: update ownerDocument.element_dict or find other solution try: self.childNodes.remove(oldChild) @@ -191,8 +199,8 @@ def _append_child(self, node): node.__dict__["parentNode"] = self class Childless: - """Mixin that makes childless-ness easy to implement and avoids - the complexity of the Node methods that deal with children. + """ Mixin that makes childless-ness easy to implement and avoids + the complexity of the Node methods that deal with children. """ attributes = None @@ -207,6 +215,7 @@ class Childless: return None def appendChild(self, node): + """ Raises an error """ raise xml.dom.HierarchyRequestErr( self.tagName + " nodes cannot have children") @@ -214,14 +223,17 @@ class Childless: return False def insertBefore(self, newChild, refChild): + """ Raises an error """ raise xml.dom.HierarchyRequestErr( self.tagName + " nodes do not have children") def removeChild(self, oldChild): + """ Raises an error """ raise xml.dom.NotFoundErr( self.tagName + " nodes do not have children") def replaceChild(self, newChild, oldChild): + """ Raises an error """ raise xml.dom.HierarchyRequestErr( self.tagName + " nodes do not have children") @@ -247,8 +259,12 @@ class CDATASection(Childless, Text): nodeType = Node.CDATA_SECTION_NODE def toXml(self,level,f): + """ Generate XML output of the node. If the text contains "]]>", then + escape it by going out of CDATA mode (]]>), then write the string + and then go into CDATA mode again. (' % self.data) + f.write('' % self.data.replace(']]>',']]>]]>" % (r[1].lower().replace('-',''), self.tagName) + def get_knownns(self, prefix): + """ Odfpy maintains a list of known namespaces. In some cases a prefix is used, and + we need to know which namespace it resolves to. + """ + global nsdict + for ns,p in nsdict.items(): + if p == prefix: return ns + return None + def get_nsprefix(self, namespace): + """ Odfpy maintains a list of known namespaces. In some cases we have a namespace URL, + and needs to look up or assign the prefix for it. + """ if namespace is None: namespace = "" prefix = _nsassign(namespace) if not self.namespaces.has_key(namespace): @@ -339,6 +367,9 @@ class Element(Node): self.ownerDocument.rebuild_caches(element) def addText(self, text, check_grammar=True): + """ Adds text to an element + Setting check_grammar=False turns off grammar checking + """ if check_grammar and self.qname not in grammar.allows_text: raise IllegalText, "The <%s> element does not allow text" % self.tagName else: @@ -346,6 +377,9 @@ class Element(Node): self.appendChild(Text(text)) def addCDATA(self, cdata, check_grammar=True): + """ Adds CDATA to an element + Setting check_grammar=False turns off grammar checking + """ if check_grammar and self.qname not in grammar.allows_text: raise IllegalText, "The <%s> element does not allow text" % self.tagName else: @@ -403,17 +437,18 @@ class Element(Node): # if allowed_attrs and (namespace, localpart) not in allowed_attrs: # raise AttributeError, "Attribute %s:%s is not allowed in element <%s>" % ( prefix, localpart, self.tagName) c = AttrConverters() - self.attributes[prefix + ":" + localpart] = c.convert((namespace, localpart), value, self.qname) + self.attributes[(namespace, localpart)] = c.convert((namespace, localpart), value, self) def getAttrNS(self, namespace, localpart): prefix = self.get_nsprefix(namespace) - return self.attributes.get(prefix + ":" + localpart) + return self.attributes.get((namespace, localpart)) def removeAttrNS(self, namespace, localpart): - prefix = self.get_nsprefix(namespace) - del self.attributes[prefix + ":" + localpart] + del self.attributes[(namespace, localpart)] def getAttribute(self, attr): + """ Get an attribute value. The method knows which namespace the attribute is in + """ allowed_attrs = self.allowed_attributes() if allowed_attrs is None: if type(attr) == type(()): @@ -432,8 +467,9 @@ class Element(Node): if level == 0: for namespace, prefix in self.namespaces.items(): f.write(' xmlns:' + prefix + '="'+ _escape(str(namespace))+'"') - for attkey in self.attributes.keys(): - f.write(' '+_escape(str(attkey))+'='+_quoteattr(unicode(self.attributes[attkey]).encode('utf-8'))) + for qname in self.attributes.keys(): + prefix = self.get_nsprefix(qname[0]) + f.write(' '+_escape(str(prefix+':'+qname[1]))+'='+_quoteattr(unicode(self.attributes[qname]).encode('utf-8'))) f.write('>') def write_close_tag(self, level, f): @@ -445,8 +481,9 @@ class Element(Node): if level == 0: for namespace, prefix in self.namespaces.items(): f.write(' xmlns:' + prefix + '="'+ _escape(str(namespace))+'"') - for attkey in self.attributes.keys(): - f.write(' '+_escape(str(attkey))+'='+_quoteattr(unicode(self.attributes[attkey]).encode('utf-8'))) + for qname in self.attributes.keys(): + prefix = self.get_nsprefix(qname[0]) + f.write(' '+_escape(str(prefix+':'+qname[1]))+'='+_quoteattr(unicode(self.attributes[qname]).encode('utf-8'))) if self.childNodes: f.write('>') for element in self.childNodes: @@ -464,6 +501,7 @@ class Element(Node): return accumulator def getElementsByType(self, element): + """ Gets elements based on the type, which is function from text.py, draw.py etc. """ obj = element(check_grammar=False) return self._getElementsByObj(obj,[]) diff --git a/src/odf/grammar.py b/src/odf/grammar.py index 09ec02cbaa..d5d8d5970e 100644 --- a/src/odf/grammar.py +++ b/src/odf/grammar.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2006-2009 Søren Roug, European Environment Agency +# Copyright (C) 2006-2010 Søren Roug, European Environment Agency # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/src/odf/load.py b/src/odf/load.py index 1f0e45ea23..e48fcaa412 100644 --- a/src/odf/load.py +++ b/src/odf/load.py @@ -63,8 +63,8 @@ class LoadParser(handler.ContentHandler): self.level = self.level + 1 # Add any accumulated text content - content = ''.join(self.data).strip() - if len(content) > 0: + content = ''.join(self.data) + if len(content.strip()) > 0: self.parent.addText(content, check_grammar=False) self.data = [] # Create the element diff --git a/src/odf/namespaces.py b/src/odf/namespaces.py index 3109210bb5..96ea958e79 100644 --- a/src/odf/namespaces.py +++ b/src/odf/namespaces.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2006-2009 Søren Roug, European Environment Agency +# Copyright (C) 2006-2010 Søren Roug, European Environment Agency # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -17,7 +17,7 @@ # # Contributor(s): # -TOOLSVERSION = u"ODFPY/0.9.2dev" +TOOLSVERSION = u"ODFPY/0.9.4dev" ANIMNS = u"urn:oasis:names:tc:opendocument:xmlns:animation:1.0" DBNS = u"urn:oasis:names:tc:opendocument:xmlns:database:1.0" @@ -28,19 +28,23 @@ DCNS = u"http://purl.org/dc/elements/1.1/" DOMNS = u"http://www.w3.org/2001/xml-events" DR3DNS = u"urn:oasis:names:tc:opendocument:xmlns:dr3d:1.0" DRAWNS = u"urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" +FIELDNS = u"urn:openoffice:names:experimental:ooo-ms-interop:xmlns:field:1.0" FONS = u"urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" FORMNS = u"urn:oasis:names:tc:opendocument:xmlns:form:1.0" +GRDDLNS = u"http://www.w3.org/2003/g/data-view#" KOFFICENS = u"http://www.koffice.org/2005/" MANIFESTNS = u"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" MATHNS = u"http://www.w3.org/1998/Math/MathML" METANS = u"urn:oasis:names:tc:opendocument:xmlns:meta:1.0" NUMBERNS = u"urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0" OFFICENS = u"urn:oasis:names:tc:opendocument:xmlns:office:1.0" +OFNS = u"urn:oasis:names:tc:opendocument:xmlns:of:1.2" OOONS = u"http://openoffice.org/2004/office" OOOWNS = u"http://openoffice.org/2004/writer" OOOCNS = u"http://openoffice.org/2004/calc" PRESENTATIONNS = u"urn:oasis:names:tc:opendocument:xmlns:presentation:1.0" RDFANS = u"http://docs.oasis-open.org/opendocument/meta/rdfa#" +RPTNS = u"http://openoffice.org/2005/report" SCRIPTNS = u"urn:oasis:names:tc:opendocument:xmlns:script:1.0" SMILNS = u"urn:oasis:names:tc:opendocument:xmlns:smil-compatible:1.0" STYLENS = u"urn:oasis:names:tc:opendocument:xmlns:style:1.0" @@ -50,7 +54,8 @@ TEXTNS = u"urn:oasis:names:tc:opendocument:xmlns:text:1.0" XFORMSNS = u"http://www.w3.org/2002/xforms" XLINKNS = u"http://www.w3.org/1999/xlink" XMLNS = u"http://www.w3.org/XML/1998/namespace" - +XSDNS = u"http://www.w3.org/2001/XMLSchema" +XSINS = u"http://www.w3.org/2001/XMLSchema-instance" nsdict = { ANIMNS: u'anim', @@ -61,19 +66,23 @@ nsdict = { DOMNS: u'dom', DR3DNS: u'dr3d', DRAWNS: u'draw', + FIELDNS: u'field', FONS: u'fo', FORMNS: u'form', + GRDDLNS: u'grddl', KOFFICENS: u'koffice', MANIFESTNS: u'manifest', MATHNS: u'math', METANS: u'meta', NUMBERNS: u'number', OFFICENS: u'office', + OFNS: u'of', OOONS: u'ooo', OOOWNS: u'ooow', OOOCNS: u'oooc', PRESENTATIONNS: u'presentation', RDFANS: u'rdfa', + RPTNS: u'rpt', SCRIPTNS: u'script', SMILNS: u'smil', STYLENS: u'style', @@ -83,4 +92,6 @@ nsdict = { XFORMSNS: u'xforms', XLINKNS: u'xlink', XMLNS: u'xml', + XSDNS: u'xsd', + XSINS: u'xsi', } diff --git a/src/odf/odf2xhtml.py b/src/odf/odf2xhtml.py index 53a3e87dc2..390d407d16 100644 --- a/src/odf/odf2xhtml.py +++ b/src/odf/odf2xhtml.py @@ -1,6 +1,6 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -# Copyright (C) 2006-2007 Søren Roug, European Environment Agency +# Copyright (C) 2006-2010 Søren Roug, European Environment Agency # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -20,15 +20,18 @@ # #import pdb #pdb.set_trace() -import zipfile -from xml.sax import handler, expatreader -from xml.sax.xmlreader import InputSource +from xml.sax import handler from xml.sax.saxutils import escape, quoteattr -from cStringIO import StringIO +from xml.dom import Node -from namespaces import DCNS, DRAWNS, FONS, \ - METANS, NUMBERNS, OFFICENS, PRESENTATIONNS, \ - STYLENS, SVGNS, TABLENS, TEXTNS, XLINKNS +from opendocument import load + +from namespaces import ANIMNS, CHARTNS, CONFIGNS, DCNS, DR3DNS, DRAWNS, FONS, \ + FORMNS, MATHNS, METANS, NUMBERNS, OFFICENS, PRESENTATIONNS, SCRIPTNS, \ + SMILNS, STYLENS, SVGNS, TABLENS, TEXTNS, XLINKNS + +if False: # Added by Kovid + DR3DNS, MATHNS, CHARTNS, CONFIGNS, ANIMNS, FORMNS, SMILNS, SCRIPTNS # Handling of styles # @@ -72,8 +75,8 @@ class StyleToCSS: (FONS,u"border-left"): self.c_fo, (FONS,u"border-right"): self.c_fo, (FONS,u"border-top"): self.c_fo, - (FONS,u"break-after"): self.c_break, - (FONS,u"break-before"): self.c_break, + (FONS,u"break-after"): self.c_break, # Added by Kovid + (FONS,u"break-before"): self.c_break,# Added by Kovid (FONS,u"color"): self.c_fo, (FONS,u"font-family"): self.c_fo, (FONS,u"font-size"): self.c_fo, @@ -136,7 +139,7 @@ class StyleToCSS: selector = rule[1] sdict[selector] = val - def c_break(self, ruleset, sdict, rule, val): + def c_break(self, ruleset, sdict, rule, val): # Added by Kovid property = 'page-' + rule[1] values = {'auto': 'auto', 'column': 'always', 'page': 'always', 'even-page': 'left', 'odd-page': 'right', @@ -346,13 +349,16 @@ class ODF2XHTML(handler.ContentHandler): self.elements = { (DCNS, 'title'): (self.s_processcont, self.e_dc_title), (DCNS, 'language'): (self.s_processcont, self.e_dc_contentlanguage), - (DCNS, 'creator'): (self.s_processcont, self.e_dc_metatag), + (DCNS, 'creator'): (self.s_processcont, self.e_dc_creator), (DCNS, 'description'): (self.s_processcont, self.e_dc_metatag), (DCNS, 'date'): (self.s_processcont, self.e_dc_metatag), + (DRAWNS, 'custom-shape'): (self.s_custom_shape, self.e_custom_shape), (DRAWNS, 'frame'): (self.s_draw_frame, self.e_draw_frame), (DRAWNS, 'image'): (self.s_draw_image, None), (DRAWNS, 'fill-image'): (self.s_draw_fill_image, None), (DRAWNS, "layer-set"):(self.s_ignorexml, None), + (DRAWNS, 'object'): (self.s_draw_object, None), + (DRAWNS, 'object-ole'): (self.s_draw_object_ole, None), (DRAWNS, 'page'): (self.s_draw_page, self.e_draw_page), (DRAWNS, 'text-box'): (self.s_draw_textbox, self.e_draw_textbox), (METANS, 'creation-date'):(self.s_processcont, self.e_dc_metatag), @@ -364,7 +370,9 @@ class ODF2XHTML(handler.ContentHandler): (NUMBERNS, "date-style"):(self.s_ignorexml, None), (NUMBERNS, "number-style"):(self.s_ignorexml, None), (NUMBERNS, "text-style"):(self.s_ignorexml, None), + (OFFICENS, "annotation"):(self.s_ignorexml, None), (OFFICENS, "automatic-styles"):(self.s_office_automatic_styles, None), + (OFFICENS, "document"):(self.s_office_document_content, self.e_office_document_content), (OFFICENS, "document-content"):(self.s_office_document_content, self.e_office_document_content), (OFFICENS, "forms"):(self.s_ignorexml, None), (OFFICENS, "master-styles"):(self.s_office_master_styles, None), @@ -374,6 +382,7 @@ class ODF2XHTML(handler.ContentHandler): (OFFICENS, "styles"):(self.s_office_styles, None), (OFFICENS, "text"):(self.s_office_text, self.e_office_text), (OFFICENS, "scripts"):(self.s_ignorexml, None), + (OFFICENS, "settings"):(self.s_ignorexml, None), (PRESENTATIONNS, "notes"):(self.s_ignorexml, None), # (STYLENS, "default-page-layout"):(self.s_style_default_page_layout, self.e_style_page_layout), (STYLENS, "default-page-layout"):(self.s_ignorexml, None), @@ -389,8 +398,8 @@ class ODF2XHTML(handler.ContentHandler): # (STYLENS, "header-style"):(self.s_style_header_style, None), (STYLENS, "master-page"):(self.s_style_master_page, None), (STYLENS, "page-layout-properties"):(self.s_style_handle_properties, None), -# (STYLENS, "page-layout"):(self.s_style_page_layout, self.e_style_page_layout), - (STYLENS, "page-layout"):(self.s_ignorexml, None), + (STYLENS, "page-layout"):(self.s_style_page_layout, self.e_style_page_layout), +# (STYLENS, "page-layout"):(self.s_ignorexml, None), (STYLENS, "paragraph-properties"):(self.s_style_handle_properties, None), (STYLENS, "style"):(self.s_style_style, self.e_style_style), (STYLENS, "table-cell-properties"):(self.s_style_handle_properties, None), @@ -407,6 +416,10 @@ class ODF2XHTML(handler.ContentHandler): (TEXTNS, "alphabetical-index-source"):(self.s_text_x_source, self.e_text_x_source), (TEXTNS, "bibliography-configuration"):(self.s_ignorexml, None), (TEXTNS, "bibliography-source"):(self.s_text_x_source, self.e_text_x_source), + (TEXTNS, 'bookmark'): (self.s_text_bookmark, None), + (TEXTNS, 'bookmark-start'): (self.s_text_bookmark, None), + (TEXTNS, 'bookmark-ref'): (self.s_text_bookmark_ref, self.e_text_a), + (TEXTNS, 'bookmark-ref-start'): (self.s_text_bookmark_ref, None), (TEXTNS, 'h'): (self.s_text_h, self.e_text_h), (TEXTNS, "illustration-index-source"):(self.s_text_x_source, self.e_text_x_source), (TEXTNS, 'line-break'):(self.s_text_line_break, None), @@ -430,10 +443,66 @@ class ODF2XHTML(handler.ContentHandler): (TEXTNS, "user-index-source"):(self.s_text_x_source, self.e_text_x_source), } if embedable: - self.elements[(OFFICENS, u"text")] = (None,None) - self.elements[(OFFICENS, u"spreadsheet")] = (None,None) - self.elements[(OFFICENS, u"presentation")] = (None,None) - self.elements[(OFFICENS, u"document-content")] = (None,None) + self.make_embedable() + self._resetobject() + + def set_plain(self): + """ Tell the parser to not generate CSS """ + self.generate_css = False + + def set_embedable(self): + """ Tells the converter to only output the parts inside the """ + self.elements[(OFFICENS, u"text")] = (None,None) + self.elements[(OFFICENS, u"spreadsheet")] = (None,None) + self.elements[(OFFICENS, u"presentation")] = (None,None) + self.elements[(OFFICENS, u"document-content")] = (None,None) + + + def add_style_file(self, stylefilename, media=None): + """ Add a link to an external style file. + Also turns of the embedding of styles in the HTML + """ + self.use_internal_css = False + self.stylefilename = stylefilename + if media: + self.metatags.append('\n' % (stylefilename,media)) + else: + self.metatags.append('\n' % (stylefilename)) + + def _resetfootnotes(self): + # Footnotes and endnotes + self.notedict = {} + self.currentnote = 0 + self.notebody = '' + + def _resetobject(self): + self.lines = [] + self._wfunc = self._wlines + self.xmlfile = '' + self.title = '' + self.language = '' + self.creator = '' + self.data = [] + self.tagstack = TagStack() + self.htmlstack = [] + self.pstack = [] + self.processelem = True + self.processcont = True + self.listtypes = {} + self.headinglevels = [0, 0,0,0,0,0, 0,0,0,0,0] # level 0 to 10 + self.use_internal_css = True + self.cs = StyleToCSS() + self.anchors = {} + + # Style declarations + self.stylestack = [] + self.styledict = {} + self.currentstyle = None + + self._resetfootnotes() + + # Tags from meta.xml + self.metatags = [] def writeout(self, s): @@ -447,6 +516,7 @@ class ODF2XHTML(handler.ContentHandler): def opentag(self, tag, attrs={}, block=False): """ Create an open HTML tag """ + self.htmlstack.append((tag,attrs,block)) a = [] for key,val in attrs.items(): a.append('''%s=%s''' % (key, quoteattr(val))) @@ -458,6 +528,8 @@ class ODF2XHTML(handler.ContentHandler): self.writeout("\n") def closetag(self, tag, block=True): + """ Close an open HTML tag """ + self.htmlstack.pop() self.writeout("" % tag) if block == True: self.writeout("\n") @@ -468,17 +540,13 @@ class ODF2XHTML(handler.ContentHandler): a.append('''%s=%s''' % (key, quoteattr(val))) self.writeout("<%s %s/>\n" % (tag, " ".join(a))) +#-------------------------------------------------- +# Interface to parser #-------------------------------------------------- def characters(self, data): if self.processelem and self.processcont: self.data.append(data) - def handle_starttag(self, tag, method, attrs): - method(tag,attrs) - - def handle_endtag(self, tag, attrs, method): - method(tag, attrs) - def startElementNS(self, tag, qname, attrs): self.pstack.append( (self.processelem, self.processcont) ) if self.processelem: @@ -499,6 +567,13 @@ class ODF2XHTML(handler.ContentHandler): self.unknown_endtag(tag, attrs) self.processelem, self.processcont = self.pstack.pop() +#-------------------------------------------------- + def handle_starttag(self, tag, method, attrs): + method(tag,attrs) + + def handle_endtag(self, tag, attrs, method): + method(tag, attrs) + def unknown_starttag(self, tag, attrs): pass @@ -512,18 +587,21 @@ class ODF2XHTML(handler.ContentHandler): self.processelem = False def s_ignorecont(self, tag, attrs): + """ Stop processing the text nodes """ self.processcont = False def s_processcont(self, tag, attrs): + """ Start processing the text nodes """ self.processcont = True def classname(self, attrs): """ Generate a class name from a style name """ - c = attrs[(TEXTNS,'style-name')] + c = attrs.get((TEXTNS,'style-name'),'') c = c.replace(".","_") return c def get_anchor(self, name): + """ Create a unique anchor id for a href name """ if not self.anchors.has_key(name): # Changed by Kovid self.anchors[name] = "anchor%d" % (len(self.anchors) + 1) @@ -543,8 +621,8 @@ class ODF2XHTML(handler.ContentHandler): def e_dc_title(self, tag, attrs): """ Get the title from the meta data and create a HTML """ - self.metatags.append('<title>%s\n' % escape(''.join(self.data))) self.title = ''.join(self.data) + #self.metatags.append('%s\n' % escape(self.title)) self.data = [] def e_dc_metatag(self, tag, attrs): @@ -556,13 +634,57 @@ class ODF2XHTML(handler.ContentHandler): def e_dc_contentlanguage(self, tag, attrs): """ Set the content language. Identifies the targeted audience """ - self.metatags.append('\n' % ''.join(self.data)) + self.language = ''.join(self.data) + self.metatags.append('\n' % escape(self.language)) self.data = [] + def e_dc_creator(self, tag, attrs): + """ Set the content creator. Identifies the targeted audience + """ + self.creator = ''.join(self.data) + self.metatags.append('\n' % escape(self.creator)) + self.data = [] + + def s_custom_shape(self, tag, attrs): + """ A is made into a
in HTML which is then styled + """ + anchor_type = attrs.get((TEXTNS,'anchor-type'),'notfound') + htmltag = 'div' + name = "G-" + attrs.get( (DRAWNS,'style-name'), "") + if name == 'G-': + name = "PR-" + attrs.get( (PRESENTATIONNS,'style-name'), "") + name = name.replace(".","_") + if anchor_type == "paragraph": + style = 'position:absolute;' + elif anchor_type == 'char': + style = "position:absolute;" + elif anchor_type == 'as-char': + htmltag = 'div' + style = '' + else: + style = "position: absolute;" + if attrs.has_key( (SVGNS,"width") ): + style = style + "width:" + attrs[(SVGNS,"width")] + ";" + if attrs.has_key( (SVGNS,"height") ): + style = style + "height:" + attrs[(SVGNS,"height")] + ";" + if attrs.has_key( (SVGNS,"x") ): + style = style + "left:" + attrs[(SVGNS,"x")] + ";" + if attrs.has_key( (SVGNS,"y") ): + style = style + "top:" + attrs[(SVGNS,"y")] + ";" + if self.generate_css: + self.opentag(htmltag, {'class': name, 'style': style}) + else: + self.opentag(htmltag) + + def e_custom_shape(self, tag, attrs): + """ End the + """ + self.closetag('div') + def s_draw_frame(self, tag, attrs): """ A is made into a
in HTML which is then styled """ - anchor_type = attrs.get((TEXTNS,'anchor-type'),'char') + anchor_type = attrs.get((TEXTNS,'anchor-type'),'notfound') htmltag = 'div' name = "G-" + attrs.get( (DRAWNS,'style-name'), "") if name == 'G-': @@ -576,7 +698,7 @@ class ODF2XHTML(handler.ContentHandler): htmltag = 'div' style = '' else: - style = "position: absolute;" + style = "position:absolute;" if attrs.has_key( (SVGNS,"width") ): style = style + "width:" + attrs[(SVGNS,"width")] + ";" if attrs.has_key( (SVGNS,"height") ): @@ -620,6 +742,30 @@ class ODF2XHTML(handler.ContentHandler): htmlattrs['style'] = "display: block;" self.emptytag('img', htmlattrs) + def s_draw_object(self, tag, attrs): + """ A is embedded object in the document (e.g. spreadsheet in presentation). + """ + return # Added by Kovid + objhref = attrs[(XLINKNS,"href")] + # Remove leading "./": from "./Object 1" to "Object 1" +# objhref = objhref [2:] + + # Not using os.path.join since it fails to find the file on Windows. +# objcontentpath = '/'.join([objhref, 'content.xml']) + + for c in self.document.childnodes: + if c.folder == objhref: + self._walknode(c.topnode) + + def s_draw_object_ole(self, tag, attrs): + """ A is embedded OLE object in the document (e.g. MS Graph). + """ + class_id = attrs[(DRAWNS,"class-id")] + if class_id and class_id.lower() == "00020803-0000-0000-c000-000000000046": ## Microsoft Graph 97 Chart + tagattrs = { 'name':'object_ole_graph', 'class':'ole-graph' } + self.opentag('a', tagattrs) + self.closetag('a', tagattrs) + def s_draw_page(self, tag, attrs): """ A is a slide in a presentation. We use a
element in HTML. Therefore if you convert a ODP file, you get a series of
s. @@ -655,14 +801,9 @@ class ODF2XHTML(handler.ContentHandler): def html_body(self, tag, attrs): self.writedata() - if self.generate_css: + if self.generate_css and self.use_internal_css: self.opentag('style', {'type':"text/css"}, True) self.writeout('/**/\n') self.closetag('style') @@ -670,6 +811,16 @@ class ODF2XHTML(handler.ContentHandler): self.closetag('head') self.opentag('body', block=True) + # background-color: white removed by Kovid for #9118 + # Specifying an explicit bg color prevents ebook readers + # from successfully inverting colors + default_styles = """ +img { width: 100%; height: 100%; } +* { padding: 0; margin: 0; } +body { margin: 0 1em; } +ol, ul { padding-left: 2em; } +""" + def generate_stylesheet(self): for name in self.stylestack: styles = self.styledict.get(name) @@ -689,6 +840,7 @@ class ODF2XHTML(handler.ContentHandler): styles = parentstyle self.styledict[name] = styles # Write the styles to HTML + self.writeout(self.default_styles) for name in self.stylestack: styles = self.styledict.get(name) css2 = self.cs.convert_styles(styles) @@ -730,6 +882,7 @@ class ODF2XHTML(handler.ContentHandler): self.emptytag('meta', { 'http-equiv':"Content-Type", 'content':"text/html;charset=UTF-8"}) for metaline in self.metatags: self.writeout(metaline) + self.writeout('%s\n' % escape(self.title)) def e_office_document_content(self, tag, attrs): """ Last tag """ @@ -774,7 +927,7 @@ class ODF2XHTML(handler.ContentHandler): """ Copy all attributes to a struct. We will later convert them to CSS2 """ - if self.currentstyle is None: + if self.currentstyle is None: # Added by Kovid return for key,attr in attrs.items(): self.styledict[self.currentstyle][key] = attr @@ -800,7 +953,7 @@ class ODF2XHTML(handler.ContentHandler): def s_style_font_face(self, tag, attrs): """ It is possible that the HTML browser doesn't know how to show a particular font. Luckily ODF provides generic fallbacks - Unluckily they are not the same as CSS2. + Unfortunately they are not the same as CSS2. CSS2: serif, sans-serif, cursive, fantasy, monospace ODF: roman, swiss, modern, decorative, script, system """ @@ -851,7 +1004,7 @@ class ODF2XHTML(handler.ContentHandler): """ name = attrs[(STYLENS,'name')] name = name.replace(".","_") - self.currentstyle = "@page " + name + self.currentstyle = ".PL-" + name self.stylestack.append(self.currentstyle) self.styledict[self.currentstyle] = {} @@ -882,7 +1035,7 @@ class ODF2XHTML(handler.ContentHandler): self.s_ignorexml(tag, attrs) # Short prefixes for class selectors - familyshort = {'drawing-page':'DP', 'paragraph':'P', 'presentation':'PR', + _familyshort = {'drawing-page':'DP', 'paragraph':'P', 'presentation':'PR', 'text':'S', 'section':'D', 'table':'T', 'table-cell':'TD', 'table-column':'TC', 'table-row':'TR', 'graphic':'G' } @@ -898,7 +1051,7 @@ class ODF2XHTML(handler.ContentHandler): name = name.replace(".","_") family = attrs[(STYLENS,'family')] htmlfamily = self.familymap.get(family,'unknown') - sfamily = self.familyshort.get(family,'X') + sfamily = self._familyshort.get(family,'X') name = "%s%s-%s" % (self.autoprefix, sfamily, name) parent = attrs.get( (STYLENS,'parent-style-name') ) self.currentstyle = special_styles.get(name,"."+name) @@ -943,6 +1096,7 @@ class ODF2XHTML(handler.ContentHandler): self.purgedata() def s_table_table_cell(self, tag, attrs): + """ Start a table cell """ #FIXME: number-columns-repeated § 8.1.3 #repeated = int(attrs.get( (TABLENS,'number-columns-repeated'), 1)) htmlattrs = {} @@ -960,11 +1114,13 @@ class ODF2XHTML(handler.ContentHandler): self.purgedata() def e_table_table_cell(self, tag, attrs): + """ End a table cell """ self.writedata() self.closetag('td') self.purgedata() def s_table_table_column(self, tag, attrs): + """ Start a table column """ c = attrs.get( (TABLENS,'style-name'), None) repeated = int(attrs.get( (TABLENS,'number-columns-repeated'), 1)) htmlattrs = {} @@ -975,6 +1131,7 @@ class ODF2XHTML(handler.ContentHandler): self.purgedata() def s_table_table_row(self, tag, attrs): + """ Start a table row """ #FIXME: table:number-rows-repeated c = attrs.get( (TABLENS,'style-name'), None) htmlattrs = {} @@ -984,6 +1141,7 @@ class ODF2XHTML(handler.ContentHandler): self.purgedata() def e_table_table_row(self, tag, attrs): + """ End a table row """ self.writedata() self.closetag('tr') self.purgedata() @@ -998,10 +1156,28 @@ class ODF2XHTML(handler.ContentHandler): self.purgedata() def e_text_a(self, tag, attrs): + """ End an anchor or bookmark reference """ self.writedata() self.closetag('a', False) self.purgedata() + def s_text_bookmark(self, tag, attrs): + """ Bookmark definition """ + name = attrs[(TEXTNS,'name')] + html_id = self.get_anchor(name) + self.writedata() + self.opentag('span', {'id':html_id}) + self.closetag('span', False) + self.purgedata() + + def s_text_bookmark_ref(self, tag, attrs): + """ Bookmark reference """ + name = attrs[(TEXTNS,'ref-name')] + html_id = "#" + self.get_anchor(name) + self.writedata() + self.opentag('a', {'href':html_id}) + self.purgedata() + def s_text_h(self, tag, attrs): """ Headings start """ level = int(attrs[(TEXTNS,'outline-level')]) @@ -1019,13 +1195,19 @@ class ODF2XHTML(handler.ContentHandler): self.purgedata() def e_text_h(self, tag, attrs): - """ Headings end """ + """ Headings end + Side-effect: If there is no title in the metadata, then it is taken + from the first heading of any level. + """ self.writedata() level = int(attrs[(TEXTNS,'outline-level')]) if level > 6: level = 6 # Heading levels go only to 6 in XHTML if level < 1: level = 1 lev = self.headinglevels[1:level+1] outline = '.'.join(map(str,lev) ) + heading = ''.join(self.data) + if self.title == '': self.title = heading + # Changed by Kovid tail = ''.join(self.data) anchor = self.get_anchor("%s.%s" % ( outline, tail)) anchor2 = self.get_anchor(tail) # Added by kovid to fix #7506 @@ -1037,12 +1219,14 @@ class ODF2XHTML(handler.ContentHandler): self.purgedata() def s_text_line_break(self, tag, attrs): + """ Force a line break (
) """ self.writedata() self.emptytag('br') self.purgedata() def s_text_list(self, tag, attrs): - """ To know which level we're at, we have to count the number + """ Start a list (
    or
      ) + To know which level we're at, we have to count the number of elements on the tagstack. """ name = attrs.get( (TEXTNS,'style-name') ) @@ -1056,12 +1240,13 @@ class ODF2XHTML(handler.ContentHandler): name = self.tagstack.rfindattr( (TEXTNS,'style-name') ) list_class = "%s_%d" % (name, level) if self.generate_css: - self.opentag('%s' % self.listtypes.get(list_class,'UL'), {'class': list_class }) + self.opentag('%s' % self.listtypes.get(list_class,'ul'), {'class': list_class }) else: - self.opentag('%s' % self.listtypes.get(list_class,'UL')) + self.opentag('%s' % self.listtypes.get(list_class,'ul')) self.purgedata() def e_text_list(self, tag, attrs): + """ End a list """ self.writedata() name = attrs.get( (TEXTNS,'style-name') ) level = self.tagstack.count_tags(tag) + 1 @@ -1073,14 +1258,16 @@ class ODF2XHTML(handler.ContentHandler): # textbox itself may be nested within another list. name = self.tagstack.rfindattr( (TEXTNS,'style-name') ) list_class = "%s_%d" % (name, level) - self.closetag(self.listtypes.get(list_class,'UL')) + self.closetag(self.listtypes.get(list_class,'ul')) self.purgedata() def s_text_list_item(self, tag, attrs): + """ Start list item """ self.opentag('li') self.purgedata() def e_text_list_item(self, tag, attrs): + """ End list item """ self.writedata() self.closetag('li') self.purgedata() @@ -1192,7 +1379,7 @@ class ODF2XHTML(handler.ContentHandler): if specialtag is None: specialtag = 'p' self.writedata() - if not self.data: + if not self.data: # Added by Kovid # Give substance to empty paragraphs, as rendered by OOo self.writeout(' ') self.closetag(specialtag) @@ -1255,55 +1442,30 @@ class ODF2XHTML(handler.ContentHandler): #----------------------------------------------------------------------------- def load(self, odffile): - self._odffile = odffile + """ Loads a document into the parser and parses it. + The argument can either be a filename or a document in memory. + """ + self.lines = [] + self._wfunc = self._wlines + if isinstance(odffile, basestring) \ + or hasattr(odffile, 'read'): # Added by Kovid + self.document = load(odffile) + else: + self.document = odffile + self._walknode(self.document.topnode) - def parseodf(self): - self.xmlfile = '' - self.title = '' - self.data = [] - self.tagstack = TagStack() - self.pstack = [] - self.processelem = True - self.processcont = True - self.listtypes = {} - self.headinglevels = [0, 0,0,0,0,0, 0,0,0,0,0] # level 0 to 10 - self.cs = StyleToCSS() - self.anchors = {} + def _walknode(self, node): + if node.nodeType == Node.ELEMENT_NODE: + self.startElementNS(node.qname, node.tagName, node.attributes) + for c in node.childNodes: + self._walknode(c) + self.endElementNS(node.qname, node.tagName) + if node.nodeType == Node.TEXT_NODE or node.nodeType == Node.CDATA_SECTION_NODE: + self.characters(unicode(node)) - # Style declarations - self.stylestack = [] - self.styledict = {} - self.currentstyle = None - - # Footnotes and endnotes - self.notedict = {} - self.currentnote = 0 - self.notebody = '' - - # Tags from meta.xml - self.metatags = [] - - # Extract the interesting files - z = zipfile.ZipFile(self._odffile) - - # For some reason Trac has trouble when xml.sax.make_parser() is used. - # Could it be because PyXML is installed, and therefore a different parser - # might be chosen? By calling expatreader directly we avoid this issue - parser = expatreader.create_parser() - parser.setFeature(handler.feature_namespaces, 1) - parser.setContentHandler(self) - parser.setErrorHandler(handler.ErrorHandler()) - inpsrc = InputSource() - - for xmlfile in ('meta.xml', 'styles.xml', 'content.xml'): - self.xmlfile = xmlfile - content = z.read(xmlfile) - inpsrc.setByteStream(StringIO(content)) - parser.parse(inpsrc) - z.close() def odf2xhtml(self, odffile): - """ Load a file and return XHTML + """ Load a file and return the XHTML """ self.load(odffile) return self.xhtml() @@ -1312,9 +1474,8 @@ class ODF2XHTML(handler.ContentHandler): if s != '': self.lines.append(s) def xhtml(self): - self.lines = [] - self._wfunc = self._wlines - self.parseodf() + """ Returns the xhtml + """ return ''.join(self.lines) def _writecss(self, s): @@ -1324,11 +1485,127 @@ class ODF2XHTML(handler.ContentHandler): pass def css(self): - self._wfunc = self._writenothing - self.parseodf() + """ Returns the CSS content """ self._csslines = [] self._wfunc = self._writecss self.generate_stylesheet() res = ''.join(self._csslines) + self._wfunc = self._wlines del self._csslines return res + + def save(self, outputfile, addsuffix=False): + """ Save the HTML under the filename. + If the filename is '-' then save to stdout + We have the last style filename in self.stylefilename + """ + if outputfile == '-': + import sys # Added by Kovid + outputfp = sys.stdout + else: + if addsuffix: + outputfile = outputfile + ".html" + outputfp = file(outputfile, "w") + outputfp.write(self.xhtml().encode('us-ascii','xmlcharrefreplace')) + outputfp.close() + + +class ODF2XHTMLembedded(ODF2XHTML): + """ The ODF2XHTML parses an ODF file and produces XHTML""" + + def __init__(self, lines, generate_css=True, embedable=False): + self._resetobject() + self.lines = lines + + # Tags + self.generate_css = generate_css + self.elements = { +# (DCNS, 'title'): (self.s_processcont, self.e_dc_title), +# (DCNS, 'language'): (self.s_processcont, self.e_dc_contentlanguage), +# (DCNS, 'creator'): (self.s_processcont, self.e_dc_metatag), +# (DCNS, 'description'): (self.s_processcont, self.e_dc_metatag), +# (DCNS, 'date'): (self.s_processcont, self.e_dc_metatag), + (DRAWNS, 'frame'): (self.s_draw_frame, self.e_draw_frame), + (DRAWNS, 'image'): (self.s_draw_image, None), + (DRAWNS, 'fill-image'): (self.s_draw_fill_image, None), + (DRAWNS, "layer-set"):(self.s_ignorexml, None), + (DRAWNS, 'page'): (self.s_draw_page, self.e_draw_page), + (DRAWNS, 'object'): (self.s_draw_object, None), + (DRAWNS, 'object-ole'): (self.s_draw_object_ole, None), + (DRAWNS, 'text-box'): (self.s_draw_textbox, self.e_draw_textbox), +# (METANS, 'creation-date'):(self.s_processcont, self.e_dc_metatag), +# (METANS, 'generator'):(self.s_processcont, self.e_dc_metatag), +# (METANS, 'initial-creator'): (self.s_processcont, self.e_dc_metatag), +# (METANS, 'keyword'): (self.s_processcont, self.e_dc_metatag), + (NUMBERNS, "boolean-style"):(self.s_ignorexml, None), + (NUMBERNS, "currency-style"):(self.s_ignorexml, None), + (NUMBERNS, "date-style"):(self.s_ignorexml, None), + (NUMBERNS, "number-style"):(self.s_ignorexml, None), + (NUMBERNS, "text-style"):(self.s_ignorexml, None), +# (OFFICENS, "automatic-styles"):(self.s_office_automatic_styles, None), +# (OFFICENS, "document-content"):(self.s_office_document_content, self.e_office_document_content), + (OFFICENS, "forms"):(self.s_ignorexml, None), +# (OFFICENS, "master-styles"):(self.s_office_master_styles, None), + (OFFICENS, "meta"):(self.s_ignorecont, None), +# (OFFICENS, "presentation"):(self.s_office_presentation, self.e_office_presentation), +# (OFFICENS, "spreadsheet"):(self.s_office_spreadsheet, self.e_office_spreadsheet), +# (OFFICENS, "styles"):(self.s_office_styles, None), +# (OFFICENS, "text"):(self.s_office_text, self.e_office_text), + (OFFICENS, "scripts"):(self.s_ignorexml, None), + (PRESENTATIONNS, "notes"):(self.s_ignorexml, None), +## (STYLENS, "default-page-layout"):(self.s_style_default_page_layout, self.e_style_page_layout), +# (STYLENS, "default-page-layout"):(self.s_ignorexml, None), +# (STYLENS, "default-style"):(self.s_style_default_style, self.e_style_default_style), +# (STYLENS, "drawing-page-properties"):(self.s_style_handle_properties, None), +# (STYLENS, "font-face"):(self.s_style_font_face, None), +## (STYLENS, "footer"):(self.s_style_footer, self.e_style_footer), +## (STYLENS, "footer-style"):(self.s_style_footer_style, None), +# (STYLENS, "graphic-properties"):(self.s_style_handle_properties, None), +# (STYLENS, "handout-master"):(self.s_ignorexml, None), +## (STYLENS, "header"):(self.s_style_header, self.e_style_header), +## (STYLENS, "header-footer-properties"):(self.s_style_handle_properties, None), +## (STYLENS, "header-style"):(self.s_style_header_style, None), +# (STYLENS, "master-page"):(self.s_style_master_page, None), +# (STYLENS, "page-layout-properties"):(self.s_style_handle_properties, None), +## (STYLENS, "page-layout"):(self.s_style_page_layout, self.e_style_page_layout), +# (STYLENS, "page-layout"):(self.s_ignorexml, None), +# (STYLENS, "paragraph-properties"):(self.s_style_handle_properties, None), +# (STYLENS, "style"):(self.s_style_style, self.e_style_style), +# (STYLENS, "table-cell-properties"):(self.s_style_handle_properties, None), +# (STYLENS, "table-column-properties"):(self.s_style_handle_properties, None), +# (STYLENS, "table-properties"):(self.s_style_handle_properties, None), +# (STYLENS, "text-properties"):(self.s_style_handle_properties, None), + (SVGNS, 'desc'): (self.s_ignorexml, None), + (TABLENS, 'covered-table-cell'): (self.s_ignorexml, None), + (TABLENS, 'table-cell'): (self.s_table_table_cell, self.e_table_table_cell), + (TABLENS, 'table-column'): (self.s_table_table_column, None), + (TABLENS, 'table-row'): (self.s_table_table_row, self.e_table_table_row), + (TABLENS, 'table'): (self.s_table_table, self.e_table_table), + (TEXTNS, 'a'): (self.s_text_a, self.e_text_a), + (TEXTNS, "alphabetical-index-source"):(self.s_text_x_source, self.e_text_x_source), + (TEXTNS, "bibliography-configuration"):(self.s_ignorexml, None), + (TEXTNS, "bibliography-source"):(self.s_text_x_source, self.e_text_x_source), + (TEXTNS, 'h'): (self.s_text_h, self.e_text_h), + (TEXTNS, "illustration-index-source"):(self.s_text_x_source, self.e_text_x_source), + (TEXTNS, 'line-break'):(self.s_text_line_break, None), + (TEXTNS, "linenumbering-configuration"):(self.s_ignorexml, None), + (TEXTNS, "list"):(self.s_text_list, self.e_text_list), + (TEXTNS, "list-item"):(self.s_text_list_item, self.e_text_list_item), + (TEXTNS, "list-level-style-bullet"):(self.s_text_list_level_style_bullet, self.e_text_list_level_style_bullet), + (TEXTNS, "list-level-style-number"):(self.s_text_list_level_style_number, self.e_text_list_level_style_number), + (TEXTNS, "list-style"):(None, None), + (TEXTNS, "note"):(self.s_text_note, None), + (TEXTNS, "note-body"):(self.s_text_note_body, self.e_text_note_body), + (TEXTNS, "note-citation"):(None, self.e_text_note_citation), + (TEXTNS, "notes-configuration"):(self.s_ignorexml, None), + (TEXTNS, "object-index-source"):(self.s_text_x_source, self.e_text_x_source), + (TEXTNS, 'p'): (self.s_text_p, self.e_text_p), + (TEXTNS, 's'): (self.s_text_s, None), + (TEXTNS, 'span'): (self.s_text_span, self.e_text_span), + (TEXTNS, 'tab'): (self.s_text_tab, None), + (TEXTNS, "table-index-source"):(self.s_text_x_source, self.e_text_x_source), + (TEXTNS, "table-of-content-source"):(self.s_text_x_source, self.e_text_x_source), + (TEXTNS, "user-index-source"):(self.s_text_x_source, self.e_text_x_source), + (TEXTNS, "page-number"):(None, None), + } + diff --git a/src/odf/opendocument.py b/src/odf/opendocument.py index 9fd16229f6..63196382d5 100644 --- a/src/odf/opendocument.py +++ b/src/odf/opendocument.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (C) 2006-2009 Søren Roug, European Environment Agency +# Copyright (C) 2006-2010 Søren Roug, European Environment Agency # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -41,7 +41,7 @@ IS_IMAGE = 1 # We need at least Python 2.2 assert sys.version_info[0]>=2 and sys.version_info[1] >= 2 -sys.setrecursionlimit=50 +#sys.setrecursionlimit(100) #The recursion limit is set conservative so mistakes like # s=content() s.addElement(s) won't eat up too much processor time. @@ -128,12 +128,12 @@ class OpenDocument: self.element_dict[element.qname] = [] self.element_dict[element.qname].append(element) if element.qname == (STYLENS, u'style'): - self._register_stylename(element) # Add to style dictionary + self.__register_stylename(element) # Add to style dictionary styleref = element.getAttrNS(TEXTNS,u'style-name') if styleref is not None and self._styles_ooo_fix.has_key(styleref): element.setAttrNS(TEXTNS,u'style-name', self._styles_ooo_fix[styleref]) - def _register_stylename(self, element): + def __register_stylename(self, element): ''' Register a style. But there are three style dictionaries: office:styles, office:automatic-styles and office:master-styles Chapter 14 @@ -165,7 +165,7 @@ class OpenDocument: """ Generates the full document as an XML file Always written as a bytestream in UTF-8 encoding """ - self._replaceGenerator() + self.__replaceGenerator() xml=StringIO() xml.write(_XMLPROLOGUE) self.topnode.toXml(0, xml) @@ -197,8 +197,10 @@ class OpenDocument: x.write_close_tag(0, xml) return xml.getvalue() - def manifestxml(self): - """ Generates the manifest.xml file """ + def __manifestxml(self): + """ Generates the manifest.xml file + The self.manifest isn't avaible unless the document is being saved + """ xml=StringIO() xml.write(_XMLPROLOGUE) self.manifest.toXml(0,xml) @@ -206,7 +208,7 @@ class OpenDocument: def metaxml(self): """ Generates the meta.xml file """ - self._replaceGenerator() + self.__replaceGenerator() x = DocumentMeta() x.addElement(self.meta) xml=StringIO() @@ -344,7 +346,7 @@ class OpenDocument: self.thumbnail = filecontent def addObject(self, document, objectname=None): - """ Add an object. The object must be an OpenDocument class + """ Adds an object (subdocument). The object must be an OpenDocument class The return value will be the folder in the zipfile the object is stored in """ self.childobjects.append(document) @@ -367,15 +369,16 @@ class OpenDocument: zi.compress_type = zipfile.ZIP_STORED zi.external_attr = UNIXPERMS self._z.writestr(zi, fileobj) - if hasPictures: - self.manifest.addElement(manifest.FileEntry(fullpath="%sPictures/" % folder,mediatype="")) + # According to section 17.7.3 in ODF 1.1, the pictures folder should not have a manifest entry +# if hasPictures: +# self.manifest.addElement(manifest.FileEntry(fullpath="%sPictures/" % folder, mediatype="")) # Look in subobjects subobjectnum = 1 for subobject in object.childobjects: self._savePictures(subobject,'%sObject %d/' % (folder, subobjectnum)) subobjectnum += 1 - def _replaceGenerator(self): + def __replaceGenerator(self): """ Section 3.1.1: The application MUST NOT export the original identifier belonging to the application that created the document. """ @@ -385,22 +388,29 @@ class OpenDocument: self.meta.addElement(meta.Generator(text=TOOLSVERSION)) def save(self, outputfile, addsuffix=False): - """ Save the document under the filename """ + """ Save the document under the filename. + If the filename is '-' then save to stdout + """ if outputfile == '-': outputfp = zipfile.ZipFile(sys.stdout,"w") else: if addsuffix: outputfile = outputfile + odmimetypes.get(self.mimetype,'.xxx') outputfp = zipfile.ZipFile(outputfile, "w") - self._zipwrite(outputfp) + self.__zipwrite(outputfp) outputfp.close() def write(self, outputfp): + """ User API to write the ODF file to an open file descriptor + Writes the ZIP format + """ zipoutputfp = zipfile.ZipFile(outputfp,"w") - self._zipwrite(zipoutputfp) + self.__zipwrite(zipoutputfp) - def _zipwrite(self, outputfp): - """ Write the document to an open file pointer """ + def __zipwrite(self, outputfp): + """ Write the document to an open file pointer + This is where the real work is done + """ self._z = outputfp self._now = time.localtime()[:6] self.manifest = manifest.Manifest() @@ -438,7 +448,7 @@ class OpenDocument: zi = zipfile.ZipInfo("META-INF/manifest.xml", self._now) zi.compress_type = zipfile.ZIP_DEFLATED zi.external_attr = UNIXPERMS - self._z.writestr(zi, self.manifestxml() ) + self._z.writestr(zi, self.__manifestxml() ) del self._z del self._now del self.manifest @@ -464,8 +474,8 @@ class OpenDocument: self._z.writestr(zi, object.contentxml() ) # Write settings - if self == object and self.settings.hasChildNodes(): - self.manifest.addElement(manifest.FileEntry(fullpath="settings.xml",mediatype="text/xml")) + if object.settings.hasChildNodes(): + self.manifest.addElement(manifest.FileEntry(fullpath="%ssettings.xml" % folder, mediatype="text/xml")) zi = zipfile.ZipInfo("%ssettings.xml" % folder, self._now) zi.compress_type = zipfile.ZIP_DEFLATED zi.external_attr = UNIXPERMS @@ -473,7 +483,7 @@ class OpenDocument: # Write meta if self == object: - self.manifest.addElement(manifest.FileEntry(fullpath="meta.xml",mediatype="text/xml")) + self.manifest.addElement(manifest.FileEntry(fullpath="meta.xml", mediatype="text/xml")) zi = zipfile.ZipInfo("meta.xml", self._now) zi.compress_type = zipfile.ZIP_DEFLATED zi.external_attr = UNIXPERMS @@ -497,6 +507,7 @@ class OpenDocument: return element.Text(data) def createCDATASection(self, data): + """ Method to create a CDATA section """ return element.CDATASection(cdata) def getMediaType(self): @@ -504,12 +515,14 @@ class OpenDocument: return self.mimetype def getStyleByName(self, name): + """ Finds a style object based on the name """ ncname = make_NCName(name) if self._styles_dict == {}: self.rebuild_caches() return self._styles_dict.get(ncname, None) def getElementsByType(self, element): + """ Gets elements based on the type, which is function from text.py, draw.py etc. """ obj = element(check_grammar=False) if self.element_dict == {}: self.rebuild_caches() @@ -517,53 +530,59 @@ class OpenDocument: # Convenience functions def OpenDocumentChart(): + """ Creates a chart document """ doc = OpenDocument('application/vnd.oasis.opendocument.chart') doc.chart = Chart() doc.body.addElement(doc.chart) return doc def OpenDocumentDrawing(): + """ Creates a drawing document """ doc = OpenDocument('application/vnd.oasis.opendocument.graphics') doc.drawing = Drawing() doc.body.addElement(doc.drawing) return doc def OpenDocumentImage(): + """ Creates an image document """ doc = OpenDocument('application/vnd.oasis.opendocument.image') doc.image = Image() doc.body.addElement(doc.image) return doc def OpenDocumentPresentation(): + """ Creates a presentation document """ doc = OpenDocument('application/vnd.oasis.opendocument.presentation') doc.presentation = Presentation() doc.body.addElement(doc.presentation) return doc def OpenDocumentSpreadsheet(): + """ Creates a spreadsheet document """ doc = OpenDocument('application/vnd.oasis.opendocument.spreadsheet') doc.spreadsheet = Spreadsheet() doc.body.addElement(doc.spreadsheet) return doc def OpenDocumentText(): + """ Creates a text document """ doc = OpenDocument('application/vnd.oasis.opendocument.text') doc.text = Text() doc.body.addElement(doc.text) return doc +def OpenDocumentTextMaster(): + """ Creates a text master document """ + doc = OpenDocument('application/vnd.oasis.opendocument.text-master') + doc.text = Text() + doc.body.addElement(doc.text) + return doc -def load(odffile): +def __loadxmlparts(z, manifest, doc, objectpath): from load import LoadParser from xml.sax import make_parser, handler - z = zipfile.ZipFile(odffile) - mimetype = z.read('mimetype') - doc = OpenDocument(mimetype, add_generator=False) - # Look in the manifest file to see if which of the four files there are - manifestpart = z.read('META-INF/manifest.xml') - manifest = manifestlist(manifestpart) - for xmlfile in ('settings.xml', 'meta.xml', 'content.xml', 'styles.xml'): + for xmlfile in (objectpath+'settings.xml', objectpath+'meta.xml', objectpath+'content.xml', objectpath+'styles.xml'): if not manifest.has_key(xmlfile): continue try: @@ -580,7 +599,19 @@ def load(odffile): parser.parse(inpsrc) del doc._parsing except KeyError, v: pass - # FIXME: Add subobjects correctly here + +def load(odffile): + """ Load an ODF file into memory + Returns a reference to the structure + """ + z = zipfile.ZipFile(odffile) + mimetype = z.read('mimetype') + doc = OpenDocument(mimetype, add_generator=False) + + # Look in the manifest file to see if which of the four files there are + manifestpart = z.read('META-INF/manifest.xml') + manifest = manifestlist(manifestpart) + __loadxmlparts(z, manifest, doc, '') for mentry,mvalue in manifest.items(): if mentry[:9] == "Pictures/" and len(mentry) > 9: doc.addPicture(mvalue['full-path'], mvalue['media-type'], z.read(mentry)) @@ -588,6 +619,13 @@ def load(odffile): doc.addThumbnail(z.read(mentry)) elif mentry in ('settings.xml', 'meta.xml', 'content.xml', 'styles.xml'): pass + # Load subobjects into structure + elif mentry[:7] == "Object " and len(mentry) < 11 and mentry[-1] == "/": + subdoc = OpenDocument(mvalue['media-type'], add_generator=False) + doc.addObject(subdoc, "/" + mentry[:-1]) + __loadxmlparts(z, manifest, subdoc, mentry) + elif mentry[:7] == "Object ": + pass # Don't load subobjects as opaque objects else: if mvalue['full-path'][-1] == '/': doc._extra.append(OpaqueObject(mvalue['full-path'], mvalue['media-type'], None)) @@ -612,4 +650,5 @@ def load(odffile): elif mimetype[:42] == 'application/vnd.oasis.opendocument.formula': doc.formula = b[0].firstChild return doc + # vim: set expandtab sw=4 : From 81522f6d8c400ed1a7d27de2214cfc1b5ffa563b Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 24 Feb 2011 10:57:40 -0700 Subject: [PATCH 48/55] Fix #9131 (Calibre should honor metadata in epub files when downloading/importing news items items) --- src/calibre/library/database2.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index dce0b34aef..0fa25e88fd 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -2451,7 +2451,6 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): stream.seek(0) mi = get_metadata(stream, format, use_libprs_metadata=False) stream.seek(0) - mi.series_index = 1.0 mi.tags = [_('News')] if arg['add_title_tag']: mi.tags += [arg['title']] From 1e31e39ac565dfae046124178e55052b273f9285 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 24 Feb 2011 10:58:16 -0700 Subject: [PATCH 49/55] ... --- src/calibre/library/database2.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 0fa25e88fd..c53d938297 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -2451,6 +2451,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): stream.seek(0) mi = get_metadata(stream, format, use_libprs_metadata=False) stream.seek(0) + if not mi.series_index: + mi.series_index = 1.0 mi.tags = [_('News')] if arg['add_title_tag']: mi.tags += [arg['title']] From 4cefe30cd63c51dd254bf3877bc5a1b03521cb5e Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 24 Feb 2011 11:07:13 -0700 Subject: [PATCH 50/55] Flickr Blog by Ricardo Jurado --- resources/recipes/flickr.recipe | 48 ++++++++++++++++++++++++++++++ resources/recipes/flickr_es.recipe | 47 +++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 resources/recipes/flickr.recipe create mode 100644 resources/recipes/flickr_es.recipe diff --git a/resources/recipes/flickr.recipe b/resources/recipes/flickr.recipe new file mode 100644 index 0000000000..5b0276d28c --- /dev/null +++ b/resources/recipes/flickr.recipe @@ -0,0 +1,48 @@ +__license__ = 'GPL v3' +__author__ = 'Ricardo Jurado' +__copyright__ = 'Ricardo Jurado' +__version__ = 'v0.1' +__date__ = '22 February 2011' + +''' +http://blog.flickr.net/ +''' + + +from calibre.web.feeds.news import BasicNewsRecipe + +class AdvancedUserRecipe1297031650(BasicNewsRecipe): + + title = u'Flickr Blog' + masthead_url = 'http://flickrtheblog.files.wordpress.com/2008/11/flickblog_logo.gif' + cover_url = 'http://flickrtheblog.files.wordpress.com/2008/11/flickblog_logo.gif' + publisher = u'' + + __author__ = 'Ricardo Jurado' + description = 'Pictures Blog' + category = 'Blog,Pictures' + + oldest_article = 120 + max_articles_per_feed = 10 + no_stylesheets = True + use_embedded_content = False + encoding = 'UTF-8' + remove_javascript = True + language = 'en' + + extra_css = """ + p{text-align: justify; font-size: 100%} + body{ text-align: left; font-size:100% } + h2{font-family: sans-serif; font-size:130%; font-weight:bold; text-align: justify; } + .published{font-family:Arial,Helvetica,sans-serif; font-size:80%; } + .posted{font-family:Arial,Helvetica,sans-serif; font-size:80%; } + """ + + keep_only_tags = [ + dict(name='div', attrs={'class':'entry'}) + ] + + feeds = [ + (u'BLOG', u'http://feeds.feedburner.com/Flickrblog'), + #(u'BLOG', u'http://blog.flickr.net/es/feed/atom/') + ] diff --git a/resources/recipes/flickr_es.recipe b/resources/recipes/flickr_es.recipe new file mode 100644 index 0000000000..1d9c2062eb --- /dev/null +++ b/resources/recipes/flickr_es.recipe @@ -0,0 +1,47 @@ +__license__ = 'GPL v3' +__author__ = 'Ricardo Jurado' +__copyright__ = 'Ricardo Jurado' +__version__ = 'v0.1' +__date__ = '22 February 2011' + +''' +http://blog.flickr.net/ +''' + + +from calibre.web.feeds.news import BasicNewsRecipe + +class AdvancedUserRecipe1297031650(BasicNewsRecipe): + + title = u'Flickr Blog' + masthead_url = 'http://flickrtheblog.files.wordpress.com/2008/11/flickblog_logo.gif' + cover_url = 'http://flickrtheblog.files.wordpress.com/2008/11/flickblog_logo.gif' + publisher = u'' + + __author__ = 'Ricardo Jurado' + description = 'Pictures Blog' + category = 'Blog,Pictures' + + oldest_article = 120 + max_articles_per_feed = 10 + no_stylesheets = True + use_embedded_content = False + encoding = 'UTF-8' + remove_javascript = True + language = 'es' + + extra_css = """ + p{text-align: justify; font-size: 100%} + body{ text-align: left; font-size:100% } + h2{font-family: sans-serif; font-size:130%; font-weight:bold; text-align: justify; } + .published{font-family:Arial,Helvetica,sans-serif; font-size:80%; } + .posted{font-family:Arial,Helvetica,sans-serif; font-size:80%; } + """ + + keep_only_tags = [ + dict(name='div', attrs={'class':'entry'}) + ] + + feeds = [ + (u'BLOG', u'http://blog.flickr.net/es/feed/atom/') + ] From 14c64a2e1c58fe2d2abe709fbfb814bf6f9fab6e Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 24 Feb 2011 11:29:05 -0700 Subject: [PATCH 51/55] Comic Input: Add option to not add links to individual pages to the Table fo Contents when converting CBC files --- src/calibre/ebooks/comic/input.py | 13 ++++++--- src/calibre/gui2/convert/comic_input.py | 3 +- src/calibre/gui2/convert/comic_input.ui | 39 +++++++++++++++---------- 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/calibre/ebooks/comic/input.py b/src/calibre/ebooks/comic/input.py index c9b11e31f2..7710d41fb3 100755 --- a/src/calibre/ebooks/comic/input.py +++ b/src/calibre/ebooks/comic/input.py @@ -304,6 +304,10 @@ class ComicInput(InputFormatPlugin): help=_('Specify the image size as widthxheight pixels. Normally,' ' an image size is automatically calculated from the output ' 'profile, this option overrides it.')), + OptionRecommendation(name='dont_add_comic_pages_to_toc', recommended_value=False, + help=_('When converting a CBC do not add links to each page to' + ' the TOC. Note this only applies if the TOC has more than one' + ' section')), ]) recommendations = set([ @@ -449,10 +453,11 @@ class ComicInput(InputFormatPlugin): wrappers = comic[2] stoc = toc.add_item(href(wrappers[0]), None, comic[0], play_order=po) - for i, x in enumerate(wrappers): - stoc.add_item(href(x), None, - _('Page')+' %d'%(i+1), play_order=po) - po += 1 + if not opts.dont_add_comic_pages_to_toc: + for i, x in enumerate(wrappers): + stoc.add_item(href(x), None, + _('Page')+' %d'%(i+1), play_order=po) + po += 1 opf.set_toc(toc) m, n = open('metadata.opf', 'wb'), open('toc.ncx', 'wb') opf.render(m, n, 'toc.ncx') diff --git a/src/calibre/gui2/convert/comic_input.py b/src/calibre/gui2/convert/comic_input.py index f7f8023c0e..ed8053b8e6 100644 --- a/src/calibre/gui2/convert/comic_input.py +++ b/src/calibre/gui2/convert/comic_input.py @@ -22,7 +22,8 @@ class PluginWidget(Widget, Ui_Form): ['colors', 'dont_normalize', 'keep_aspect_ratio', 'right2left', 'despeckle', 'no_sort', 'no_process', 'landscape', 'dont_sharpen', 'disable_trim', 'wide', 'output_format', - 'dont_grayscale', 'comic_image_size'] + 'dont_grayscale', 'comic_image_size', + 'dont_add_comic_pages_to_toc'] ) self.db, self.book_id = db, book_id for x in get_option('output_format').option.choices: diff --git a/src/calibre/gui2/convert/comic_input.ui b/src/calibre/gui2/convert/comic_input.ui index 52c0ad2bb5..676032942f 100644 --- a/src/calibre/gui2/convert/comic_input.ui +++ b/src/calibre/gui2/convert/comic_input.ui @@ -14,7 +14,7 @@ Form - + &Number of Colors: @@ -24,7 +24,7 @@ - + 8 @@ -37,70 +37,70 @@ - + Disable &normalize - + Keep &aspect ratio - + Disable &Sharpening - + Disable &Trimming - + &Wide - + &Landscape - + &Right to left - + Don't so&rt - + De&speckle - + Qt::Vertical @@ -120,7 +120,7 @@ - + &Output format: @@ -130,7 +130,7 @@ - + @@ -140,7 +140,7 @@ - + Override image &size: @@ -150,9 +150,16 @@ - + + + + + Don't add links to &pages to the Table of Contents for CBC files + + + From 8e0efbbb5fb8a7b5ba066c97fd2294866ea88934 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 24 Feb 2011 12:13:45 -0700 Subject: [PATCH 52/55] EPUB Output: Try to ensure that the cover image always has an id="cover" to workaround Nook cover reading bug. Fixes #8182 (Book cover problem when converting to epub for Nookcolor) --- src/calibre/ebooks/oeb/output.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/calibre/ebooks/oeb/output.py b/src/calibre/ebooks/oeb/output.py index 585b56c7b6..6709141a01 100644 --- a/src/calibre/ebooks/oeb/output.py +++ b/src/calibre/ebooks/oeb/output.py @@ -32,6 +32,12 @@ class OEBOutput(OutputFormatPlugin): for key in (OPF_MIME, NCX_MIME, PAGE_MAP_MIME): href, root = results.pop(key, [None, None]) if root is not None: + if key == OPF_MIME: + try: + self.workaround_nook_cover_bug(root) + except: + self.log.exception('Something went wrong while trying to' + ' workaround Nook cover bug, ignoring') raw = etree.tostring(root, pretty_print=True, encoding='utf-8', xml_declaration=True) if key == OPF_MIME: @@ -49,3 +55,24 @@ class OEBOutput(OutputFormatPlugin): with open(path, 'wb') as f: f.write(str(item)) item.unload_data_from_memory(memory=path) + + def workaround_nook_cover_bug(self, root): # {{{ + cov = root.xpath('//*[local-name() = "meta" and @name="cover" and' + ' @content != "cover"]') + if len(cov) == 1: + manpath = ('//*[local-name() = "manifest"]/*[local-name() = "item" ' + ' and @id="%s" and @media-type]') + cov = cov[0] + covid = cov.get('content') + manifest_item = root.xpath(manpath%covid) + has_cover = root.xpath(manpath%'cover') + if len(manifest_item) == 1 and not has_cover and \ + manifest_item[0].get('media-type', + '').startswith('image/'): + self.log.warn('The cover image has an id != "cover". Renaming' + ' to work around Nook Color bug') + manifest_item = manifest_item[0] + manifest_item.set('id', 'cover') + cov.set('content', 'cover') + # }}} + From 90ab2881e0868b08814945d22551fe90e2d9513d Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 24 Feb 2011 12:29:58 -0700 Subject: [PATCH 53/55] Fix Gizmodo and LifeHacker recipes --- resources/recipes/gizmodo.recipe | 14 ++++++-------- resources/recipes/lifehacker.recipe | 20 +++----------------- 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/resources/recipes/gizmodo.recipe b/resources/recipes/gizmodo.recipe index 4233ef66b7..f6d3fcb782 100644 --- a/resources/recipes/gizmodo.recipe +++ b/resources/recipes/gizmodo.recipe @@ -17,10 +17,9 @@ class Gizmodo(BasicNewsRecipe): max_articles_per_feed = 100 no_stylesheets = True encoding = 'utf-8' - use_embedded_content = False + use_embedded_content = True language = 'en' masthead_url = 'http://cache.gawkerassets.com/assets/gizmodo.com/img/logo.png' - extra_css = ' body{font-family: "Lucida Grande",Helvetica,Arial,sans-serif} img{margin-bottom: 1em} ' conversion_options = { 'comment' : description @@ -29,13 +28,12 @@ class Gizmodo(BasicNewsRecipe): , 'language' : language } - remove_attributes = ['width','height'] - keep_only_tags = [dict(attrs={'class':'content permalink'})] - remove_tags_before = dict(name='h1') - remove_tags = [dict(attrs={'class':'contactinfo'})] - remove_tags_after = dict(attrs={'class':'contactinfo'}) + feeds = [(u'Articles', u'http://feeds.gawker.com/gizmodo/vip?format=xml')] + + remove_tags = [ + {'class': 'feedflare'}, + ] - feeds = [(u'Articles', u'http://feeds.gawker.com/gizmodo/full')] def preprocess_html(self, soup): return self.adeify_images(soup) diff --git a/resources/recipes/lifehacker.recipe b/resources/recipes/lifehacker.recipe index 42e32497be..ff95efc50a 100644 --- a/resources/recipes/lifehacker.recipe +++ b/resources/recipes/lifehacker.recipe @@ -16,15 +16,9 @@ class Lifehacker(BasicNewsRecipe): max_articles_per_feed = 100 no_stylesheets = True encoding = 'utf-8' - use_embedded_content = False + use_embedded_content = True language = 'en' masthead_url = 'http://cache.gawkerassets.com/assets/lifehacker.com/img/logo.png' - extra_css = ''' - body{font-family: "Lucida Grande",Helvetica,Arial,sans-serif} - img{margin-bottom: 1em} - h1{font-family :Arial,Helvetica,sans-serif; font-size:large} - h2{font-family :Arial,Helvetica,sans-serif; font-size:x-small} - ''' conversion_options = { 'comment' : description , 'tags' : category @@ -32,20 +26,12 @@ class Lifehacker(BasicNewsRecipe): , 'language' : language } - remove_attributes = ['width', 'height', 'style'] - remove_tags_before = dict(name='h1') - keep_only_tags = [dict(id='container')] - remove_tags_after = dict(attrs={'class':'post-body'}) remove_tags = [ - dict(id="sharemenu"), - {'class': 'related'}, + {'class': 'feedflare'}, ] - feeds = [(u'Articles', u'http://feeds.gawker.com/lifehacker/full')] + feeds = [(u'Articles', u'http://feeds.gawker.com/lifehacker/vip?format=xml')] def preprocess_html(self, soup): return self.adeify_images(soup) - def print_version(self, url): - return url.replace('#!', '?_escaped_fragment_=') - From eb3d1aa424b41cb09411db66f34725c2978a7d05 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 24 Feb 2011 12:58:32 -0700 Subject: [PATCH 54/55] Kobo driver: Handle missing firmware version file --- src/calibre/devices/kobo/driver.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index 52f0563c7b..f1c0d3f3d3 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -78,9 +78,13 @@ class KOBO(USBMS): else self._main_prefix # Determine the firmware version - f = open(self.normalize_path(self._main_prefix + '.kobo/version'), 'r') - self.fwversion = f.readline().split(',')[2] - f.close() + try: + with open(self.normalize_path(self._main_prefix + '.kobo/version'), + 'rb') as f: + self.fwversion = f.readline().split(',')[2] + except: + self.fwversion = 'unknown' + if self.fwversion != '1.0' and self.fwversion != '1.4': self.has_kepubs = True debug_print('Version of firmware: ', self.fwversion, 'Has kepubs:', self.has_kepubs) @@ -161,7 +165,7 @@ class KOBO(USBMS): return changed connection = sqlite.connect(self.normalize_path(self._main_prefix + '.kobo/KoboReader.sqlite')) - + # return bytestrings if the content cannot the decoded as unicode connection.text_factory = lambda x: unicode(x, "utf-8", "ignore") @@ -234,7 +238,7 @@ class KOBO(USBMS): debug_print('delete_via_sql: ContentID: ', ContentID, 'ContentType: ', ContentType) connection = sqlite.connect(self.normalize_path(self._main_prefix + '.kobo/KoboReader.sqlite')) - + # return bytestrings if the content cannot the decoded as unicode connection.text_factory = lambda x: unicode(x, "utf-8", "ignore") @@ -511,7 +515,7 @@ class KOBO(USBMS): # the last book from the collection the list of books is empty # and the removal of the last book would not occur connection = sqlite.connect(self.normalize_path(self._main_prefix + '.kobo/KoboReader.sqlite')) - + # return bytestrings if the content cannot the decoded as unicode connection.text_factory = lambda x: unicode(x, "utf-8", "ignore") From e82f0068736eb22437d7da8b635a52aa03b634ea Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 24 Feb 2011 15:20:44 -0700 Subject: [PATCH 55/55] Driver for the Wexler T7001 --- src/calibre/customize/builtins.py | 4 ++-- src/calibre/devices/teclast/driver.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/calibre/customize/builtins.py b/src/calibre/customize/builtins.py index 4f3574559e..cd4c866562 100644 --- a/src/calibre/customize/builtins.py +++ b/src/calibre/customize/builtins.py @@ -571,7 +571,7 @@ from calibre.devices.binatone.driver import README from calibre.devices.hanvon.driver import N516, EB511, ALEX, AZBOOKA, THEBOOK from calibre.devices.edge.driver import EDGE from calibre.devices.teclast.driver import TECLAST_K3, NEWSMY, IPAPYRUS, \ - SOVOS, PICO, SUNSTECH_EB700, ARCHOS7O, STASH + SOVOS, PICO, SUNSTECH_EB700, ARCHOS7O, STASH, WEXLER from calibre.devices.sne.driver import SNE from calibre.devices.misc import PALMPRE, AVANT, SWEEX, PDNOVEL, \ GEMEI, VELOCITYMICRO, PDNOVEL_KOBO, LUMIREAD, ALURATEK_COLOR, \ @@ -679,7 +679,7 @@ plugins += [ ELONEX, TECLAST_K3, NEWSMY, - PICO, SUNSTECH_EB700, ARCHOS7O, SOVOS, STASH, + PICO, SUNSTECH_EB700, ARCHOS7O, SOVOS, STASH, WEXLER, IPAPYRUS, EDGE, SNE, diff --git a/src/calibre/devices/teclast/driver.py b/src/calibre/devices/teclast/driver.py index 2cca0085d7..1bbab8e120 100644 --- a/src/calibre/devices/teclast/driver.py +++ b/src/calibre/devices/teclast/driver.py @@ -104,3 +104,14 @@ class STASH(TECLAST_K3): VENDOR_NAME = 'STASH' WINDOWS_MAIN_MEM = WINDOWS_CARD_A_MEM = 'W950' +class WEXLER(TECLAST_K3): + + name = 'Wexler device interface' + gui_name = 'Wexler' + description = _('Communicate with the Wexler reader.') + + FORMATS = ['epub', 'fb2', 'pdf', 'txt'] + + VENDOR_NAME = 'WEXLER' + WINDOWS_MAIN_MEM = WINDOWS_CARD_A_MEM = 'T7001' +