diff --git a/src/calibre/ebooks/metadata/opf3.py b/src/calibre/ebooks/metadata/opf3.py index a8a16e74ad..873449f35c 100644 --- a/src/calibre/ebooks/metadata/opf3.py +++ b/src/calibre/ebooks/metadata/opf3.py @@ -7,14 +7,17 @@ from __future__ import (unicode_literals, division, absolute_import, from collections import defaultdict, namedtuple from functools import wraps from future_builtins import map -import re +import re, json from lxml import etree +from calibre import prints from calibre.ebooks.metadata import check_isbn, authors_to_string, string_to_authors from calibre.ebooks.metadata.book.base import Metadata +from calibre.ebooks.metadata.book.json_codec import object_to_unicode, decode_is_multiple, encode_is_multiple from calibre.ebooks.metadata.utils import parse_opf, pretty_print_opf, ensure_unique, normalize_languages from calibre.ebooks.oeb.base import OPF2_NSMAP, OPF, DC +from calibre.utils.config import from_json, to_json from calibre.utils.date import parse_date as parse_date_, fix_only_date, is_date_undefined, isoformat from calibre.utils.iso8601 import parse_iso8601 from calibre.utils.localization import canonicalize_lang @@ -30,6 +33,9 @@ def uniq(vals): seen_add = seen.add return list(x for x in vals if x not in seen and not seen_add(x)) +def dump_dict(cats): + return json.dumps(object_to_unicode(cats or {}), ensure_ascii=False, skipkeys=True) + def XPath(x): try: return _xpath_cache[x] @@ -666,6 +672,109 @@ def set_series(root, prefixes, refines, series, series_index): set_refines(d, refines, refdef('collection-type', 'series'), refdef('group-position', '%.2g' % series_index)) # }}} +# User metadata {{{ + +def dict_reader(name, load=json.loads, try2=True): + pq = '%s:%s' % (CALIBRE_PREFIX, name) + + def reader(root, prefixes, refines): + for meta in XPath('./opf:metadata/opf:meta[@property]')(root): + val = (meta.text or '').strip() + if val: + prop = expand_prefix(meta.get('property'), prefixes) + if prop.lower() == pq: + try: + ans = load(val) + if isinstance(ans, dict): + return ans + except Exception: + continue + if try2: + for meta in XPath('./opf:metadata/opf:meta[@name="calibre:%s"]' % name)(root): + val = meta.get('content') + if val: + try: + ans = load(val) + if isinstance(ans, dict): + return ans + except Exception: + continue + return reader + +read_user_categories = dict_reader('user_categories') +read_author_link_map = dict_reader('author_link_map') + +def dict_writer(name, serialize=dump_dict, remove2=True): + pq = '%s:%s' % (CALIBRE_PREFIX, name) + + def writer(root, prefixes, refines, val): + if remove2: + for meta in XPath('./opf:metadata/opf:meta[@name="calibre:%s"]' % name)(root): + remove_element(meta, refines) + for meta in XPath('./opf:metadata/opf:meta[@property]')(root): + prop = expand_prefix(meta.get('property'), prefixes) + if prop.lower() == pq: + remove_element(meta, refines) + if val: + ensure_prefix(root, prefixes, 'calibre', CALIBRE_PREFIX) + m = XPath('./opf:metadata')(root)[0] + d = m.makeelement(OPF('meta'), attrib={'property':'calibre:%s' % name}) + d.text = serialize(val) + m.append(d) + return writer + +set_user_categories = dict_writer('user_categories') +set_author_link_map = dict_writer('author_link_map') + +def deserialize_user_metadata(val): + val = json.loads(val, object_hook=from_json) + ans = {} + for name, fm in val.iteritems(): + decode_is_multiple(fm) + ans[name] = fm + return ans +read_user_metadata3 = dict_reader('user_metadata', load=deserialize_user_metadata, try2=False) + +def read_user_metadata2(root): + ans = {} + for meta in XPath('./opf:metadata/opf:meta[starts-with(@name, "calibre:user_metadata:")]')(root): + name = meta.get('name') + name = ':'.join(name.split(':')[2:]) + if not name or not name.startswith('#'): + continue + fm = meta.get('content') + try: + fm = json.loads(fm, object_hook=from_json) + decode_is_multiple(fm) + ans[name] = fm + except Exception: + prints('Failed to read user metadata:', name) + import traceback + traceback.print_exc() + continue + return ans + +def read_user_metadata(root, prefixes, refines): + return read_user_metadata3(root, prefixes, refines) or read_user_metadata2(root) + +def serialize_user_metadata(val): + return json.dumps(object_to_unicode(val), ensure_ascii=False, default=to_json, indent=2, sort_keys=True) + +set_user_metadata3 = dict_writer('user_metadata', serialize=serialize_user_metadata, remove2=False) + +def set_user_metadata(root, prefixes, refines, val): + for meta in XPath('./opf:metadata/opf:meta[starts-with(@name, "calibre:user_metadata:")]')(root): + remove_element(meta, refines) + if val: + nval = {} + for name, fm in val.items(): + fm = fm.copy() + encode_is_multiple(fm) + nval[name] = fm + set_user_metadata3(root, prefixes, refines, nval) + +# }}} + def read_metadata(root): ans = Metadata(_('Unknown'), [_('Unknown')]) prefixes, refines = read_prefixes(root), read_refines(root) @@ -704,6 +813,10 @@ def read_metadata(root): s, si = read_series(root, prefixes, refines) if s: ans.series, ans.series_index = s, si + ans.author_link_map = read_author_link_map(root, prefixes, refines) or ans.author_link_map + ans.user_categories = read_user_categories(root, prefixes, refines) or ans.user_categories + for name, fm in (read_user_metadata(root, prefixes, refines) or {}).iteritems(): + ans.set_user_metadata(name, fm) return ans def get_metadata(stream): @@ -727,6 +840,9 @@ def apply_metadata(root, mi, cover_prefix='', cover_data=None, apply_null=False, set_tags(root, prefixes, refines, mi.tags) set_rating(root, prefixes, refines, mi.rating) set_series(root, prefixes, refines, mi.series, mi.series_index) + set_author_link_map(root, prefixes, refines, getattr(mi, 'author_link_map', None)) + set_user_categories(root, prefixes, refines, getattr(mi, 'user_categories', None)) + set_user_metadata(root, prefixes, refines, mi.get_all_user_metadata(False)) pretty_print_opf(root) diff --git a/src/calibre/ebooks/metadata/opf3_test.py b/src/calibre/ebooks/metadata/opf3_test.py index a6ef06b8ea..c21ac8a228 100644 --- a/src/calibre/ebooks/metadata/opf3_test.py +++ b/src/calibre/ebooks/metadata/opf3_test.py @@ -17,8 +17,10 @@ from calibre.ebooks.metadata.opf3 import ( read_book_producers, set_book_producers, read_timestamp, set_timestamp, read_pubdate, set_pubdate, CALIBRE_PREFIX, read_last_modified, read_comments, set_comments, read_publisher, set_publisher, read_tags, set_tags, read_rating, - set_rating, read_series, set_series + set_rating, read_series, set_series, read_user_metadata, set_user_metadata, + read_author_link_map, read_user_categories, set_author_link_map, set_user_categories ) +read_author_link_map, read_user_categories, set_author_link_map, set_user_categories TEMPLATE = '''{metadata}''' % CALIBRE_PREFIX # noqa default_refines = defaultdict(list) @@ -235,6 +237,34 @@ class TestOPF3(unittest.TestCase): self.ae(('zzz', 3.3), st(root, 'zzz', 3.3)) # }}} + def test_user_metadata(self): # {{{ + def rt(root, name): + f = globals()['read_' + name] + return f(root, read_prefixes(root), read_refines(root)) + def st(root, name, val): + f = globals()['set_' + name] + f(root, read_prefixes(root), read_refines(root), val) + return rt(root, name) + for name in 'author_link_map user_categories'.split(): + root = self.get_opf('''''' % name) + self.ae({'1':1}, rt(root, name)) + root = self.get_opf('''{"2":2}''' % (name, name)) + self.ae({'2':2}, rt(root, name)) + self.ae({'3':3}, st(root, name, {3:3})) + def ru(root): + return read_user_metadata(root, read_prefixes(root), read_refines(root)) + def su(root, val): + set_user_metadata(root, read_prefixes(root), read_refines(root), val) + return ru(root) + root = self.get_opf('''''') + self.ae({'#a': {'1': 1, 'is_multiple': {}}}, ru(root)) + root = self.get_opf('''''' + '''{"#b":{"2":2}}''') + self.ae({'#b': {'2': 2, 'is_multiple': {}}}, ru(root)) + self.ae({'#c': {'3': 3, 'is_multiple': {}, 'is_multiple2': {}}}, su(root, {'#c':{'3':3}})) + + # }}} + # Run tests {{{ def suite():