%PDF- %PDF-
| Direktori : /lib/calibre/calibre/srv/ |
| Current File : //lib/calibre/calibre/srv/metadata.py |
#!/usr/bin/env python3
# License: GPLv3 Copyright: 2015, Kovid Goyal <kovid at kovidgoyal.net>
import os
from copy import copy
from collections import namedtuple
from datetime import datetime, time
from functools import partial
from threading import Lock
from calibre.constants import config_dir
from calibre.db.categories import Tag
from calibre.ebooks.metadata.sources.identify import urls_from_identifiers
from calibre.utils.date import isoformat, UNDEFINED_DATE, local_tz
from calibre.utils.config import tweaks
from calibre.utils.formatter import EvalFormatter
from calibre.utils.file_type_icons import EXT_MAP
from calibre.utils.icu import collation_order_for_partitioning
from calibre.utils.localization import calibre_langcode_to_name
from calibre.library.comments import comments_to_html, markdown
from calibre.library.field_metadata import category_icon_map
from polyglot.builtins import iteritems, itervalues
from polyglot.urllib import quote
IGNORED_FIELDS = frozenset('cover ondevice path marked au_map'.split())
def encode_datetime(dateval):
if dateval is None:
return "None"
if not isinstance(dateval, datetime):
dateval = datetime.combine(dateval, time())
if hasattr(dateval, 'tzinfo') and dateval.tzinfo is None:
dateval = dateval.replace(tzinfo=local_tz)
if dateval <= UNDEFINED_DATE:
return None
return isoformat(dateval)
empty_val = ((), '', {})
passthrough_comment_types = {'long-text', 'short-text'}
def add_field(field, db, book_id, ans, field_metadata):
datatype = field_metadata.get('datatype')
if datatype is not None:
val = db._field_for(field, book_id)
if val is not None and val not in empty_val:
if datatype == 'datetime':
val = encode_datetime(val)
if val is None:
return
elif datatype == 'comments' or field == 'comments':
ctype = field_metadata.get('display', {}).get('interpret_as', 'html')
if ctype == 'markdown':
ans[field + '#markdown#'] = val
val = markdown(val)
elif ctype not in passthrough_comment_types:
val = comments_to_html(val)
elif datatype == 'composite' and field_metadata['display'].get('contains_html'):
val = comments_to_html(val)
ans[field] = val
def book_as_json(db, book_id):
db = db.new_api
with db.safe_read_lock:
fmts = db._formats(book_id, verify_formats=False)
ans = []
fm = {}
for fmt in fmts:
m = db.format_metadata(book_id, fmt)
if m and m.get('size', 0) > 0:
ans.append(fmt)
fm[fmt] = m['size']
ans = {'formats': ans, 'format_sizes': fm}
if not ans['formats'] and not db.has_id(book_id):
return None
fm = db.field_metadata
for field in fm.all_field_keys():
if field not in IGNORED_FIELDS:
add_field(field, db, book_id, ans, fm[field])
ids = ans.get('identifiers')
if ids:
ans['urls_from_identifiers'] = urls_from_identifiers(ids)
langs = ans.get('languages')
if langs:
ans['lang_names'] = {l:calibre_langcode_to_name(l) for l in langs}
return ans
_include_fields = frozenset(Tag.__slots__) - frozenset({
'state', 'is_editable', 'is_searchable', 'original_name', 'use_sort_as_name', 'is_hierarchical'
})
def category_as_json(items, category, display_name, count, tooltip=None, parent=None,
is_editable=True, is_gst=False, is_hierarchical=False, is_searchable=True,
is_user_category=False, is_first_letter=False):
ans = {'category': category, 'name': display_name, 'is_category':True, 'count':count}
if tooltip:
ans['tooltip'] = tooltip
if parent:
ans['parent'] = parent
if is_editable:
ans['is_editable'] = True
if is_gst:
ans['is_gst'] = True
if is_hierarchical:
ans['is_hierarchical'] = is_hierarchical
if is_searchable:
ans['is_searchable'] = True
if is_user_category:
ans['is_user_category'] = True
if is_first_letter:
ans['is_first_letter'] = True
item_id = 'c' + str(len(items))
items[item_id] = ans
return item_id
def category_item_as_json(x, clear_rating=False):
ans = {}
for k in _include_fields:
val = getattr(x, k)
if val is not None:
if k == 'original_categories':
val = tuple(val)
ans[k] = val.copy() if isinstance(val, set) else val
s = ans.get('sort')
if x.use_sort_as_name:
ans['name'] = s
if x.original_name != ans['name']:
ans['original_name'] = x.original_name
if x.use_sort_as_name or not s or s == ans['name']:
ans.pop('sort', None)
if clear_rating:
del ans['avg_rating']
return ans
CategoriesSettings = namedtuple(
'CategoriesSettings', 'dont_collapse collapse_model collapse_at sort_by'
' template using_hierarchy grouped_search_terms hidden_categories hide_empty_categories')
class GroupedSearchTerms:
__slots__ = ('keys', 'vals', 'hash')
def __init__(self, src):
self.keys = frozenset(src)
self.hash = hash(self.keys)
# We dont need to store values since this is used as part of a key for
# a cache and if the values have changed the cache will be invalidated
# for other reasons anyway (last_modified() will have changed on the
# db)
def __contains__(self, val):
return val in self.keys
def __hash__(self):
return self.hash
def __eq__(self, other):
try:
return self.keys == other.keys
except AttributeError:
return False
_icon_map = None
_icon_map_lock = Lock()
def icon_map():
global _icon_map
with _icon_map_lock:
if _icon_map is None:
from calibre.gui2 import gprefs
_icon_map = category_icon_map.copy()
custom_icons = gprefs.get('tags_browser_category_icons', {})
for k, v in iteritems(custom_icons):
if os.access(os.path.join(config_dir, 'tb_icons', v), os.R_OK):
_icon_map[k] = '_' + quote(v)
_icon_map['file_type_icons'] = {
k:'mimetypes/%s.png' % v for k, v in iteritems(EXT_MAP)
}
return _icon_map
def categories_settings(query, db, gst_container=GroupedSearchTerms):
dont_collapse = frozenset(query.get('dont_collapse', '').split(','))
partition_method = query.get('partition_method', 'first letter')
if partition_method not in {'first letter', 'disable', 'partition'}:
partition_method = 'first letter'
try:
collapse_at = max(0, int(float(query.get('collapse_at', 25))))
except Exception:
collapse_at = 25
sort_by = query.get('sort_tags_by', 'name')
if sort_by not in {'name', 'popularity', 'rating'}:
sort_by = 'name'
collapse_model = partition_method if collapse_at else 'disable'
template = None
if collapse_model != 'disable':
if sort_by != 'name':
collapse_model = 'partition'
template = tweaks['categories_collapsed_%s_template' % sort_by]
using_hierarchy = frozenset(db.pref('categories_using_hierarchy', []))
hidden_categories = frozenset(db.pref('tag_browser_hidden_categories', set()))
return CategoriesSettings(
dont_collapse, collapse_model, collapse_at, sort_by, template,
using_hierarchy, gst_container(db.pref('grouped_search_terms', {})),
hidden_categories, query.get('hide_empty_categories') == 'yes')
def create_toplevel_tree(category_data, items, field_metadata, opts):
# Create the basic tree, containing all top level categories , user
# categories and grouped search terms
last_category_node, category_node_map, root = None, {}, {'id':None, 'children':[]}
node_id_map = {}
category_nodes, recount_nodes = [], []
order = tweaks['tag_browser_category_order']
defvalue = order.get('*', 100)
categories = [category for category in field_metadata if category in category_data]
scats = sorted(categories, key=lambda x: order.get(x, defvalue))
for category in scats:
is_user_category = category.startswith('@')
is_gst, tooltip = (is_user_category and category[1:] in opts.grouped_search_terms), ''
cdata = category_data[category]
if is_gst:
tooltip = _('The grouped search term name is "{0}"').format(category)
elif category != 'news':
cust_desc = ''
fm = field_metadata[category]
if fm['is_custom']:
cust_desc = fm['display'].get('description', '')
if cust_desc:
cust_desc = '\n' + _('Description:') + ' ' + cust_desc
tooltip = _('The lookup/search name is "{0}"{1}').format(category, cust_desc)
if is_user_category:
path_parts = category.split('.')
path = ''
last_category_node = None
current_root = root
for i, p in enumerate(path_parts):
path += p
if path not in category_node_map:
last_category_node = category_as_json(
items, path, (p[1:] if i == 0 else p), len(cdata),
parent=last_category_node, tooltip=tooltip,
is_gst=is_gst, is_editable=((not is_gst) and (i == (len(path_parts)-1))),
is_hierarchical=False if is_gst else 5, is_user_category=True
)
node_id_map[last_category_node] = category_node_map[path] = node = {'id':last_category_node, 'children':[]}
category_nodes.append(last_category_node)
recount_nodes.append(node)
current_root['children'].append(node)
current_root = node
else:
current_root = category_node_map[path]
last_category_node = current_root['id']
path += '.'
else:
last_category_node = category_as_json(
items, category, field_metadata[category]['name'], len(cdata),
tooltip=tooltip
)
category_node_map[category] = node_id_map[last_category_node] = node = {'id':last_category_node, 'children':[]}
root['children'].append(node)
category_nodes.append(last_category_node)
recount_nodes.append(node)
return root, node_id_map, category_nodes, recount_nodes
def build_first_letter_list(category_items):
# Build a list of 'equal' first letters by noticing changes
# in ICU's 'ordinal' for the first letter. In this case, the
# first letter can actually be more than one letter long.
cl_list = [None] * len(category_items)
last_ordnum = 0
last_c = ' '
for idx, tag in enumerate(category_items):
if not tag.sort:
c = ' '
else:
c = icu_upper(tag.sort)
ordnum, ordlen = collation_order_for_partitioning(c)
if last_ordnum != ordnum:
last_c = c[0:ordlen]
last_ordnum = ordnum
cl_list[idx] = last_c
return cl_list
categories_with_ratings = {'authors', 'series', 'publisher', 'tags'}
def get_name_components(name):
components = list(filter(None, [t.strip() for t in name.split('.')]))
if not components or '.'.join(components) != name:
components = [name]
return components
def collapse_partition(collapse_nodes, items, category_node, idx, tag, opts, top_level_component,
cat_len, category_is_hierarchical, category_items, eval_formatter, is_gst,
last_idx, node_parent):
# Only partition at the top level. This means that we must not do a break
# until the outermost component changes.
if idx >= last_idx + opts.collapse_at and not tag.original_name.startswith(top_level_component+'.'):
last = idx + opts.collapse_at - 1 if cat_len > idx + opts.collapse_at else cat_len - 1
if category_is_hierarchical:
ct = copy(category_items[last])
components = get_name_components(ct.original_name)
ct.sort = ct.name = components[0]
# Do the first node after the last node so that the components
# array contains the right values to be used later
ct2 = copy(tag)
components = get_name_components(ct2.original_name)
ct2.sort = ct2.name = components[0]
format_data = {'last': ct, 'first':ct2}
else:
format_data = {'first': tag, 'last': category_items[last]}
name = eval_formatter.safe_format(opts.template, format_data, '##TAG_VIEW##', None)
if not name.startswith('##TAG_VIEW##'):
# Formatter succeeded
node_id = category_as_json(
items, items[category_node['id']]['category'], name, 0,
parent=category_node['id'], is_editable=False, is_gst=is_gst,
is_hierarchical=category_is_hierarchical, is_searchable=False)
node_parent = {'id':node_id, 'children':[]}
collapse_nodes.append(node_parent)
category_node['children'].append(node_parent)
last_idx = idx # remember where we last partitioned
return last_idx, node_parent
def collapse_first_letter(collapse_nodes, items, category_node, cl_list, idx, is_gst, category_is_hierarchical, collapse_letter, node_parent):
cl = cl_list[idx]
if cl != collapse_letter:
collapse_letter = cl
node_id = category_as_json(
items, items[category_node['id']]['category'], collapse_letter, 0,
parent=category_node['id'], is_editable=False, is_gst=is_gst,
is_hierarchical=category_is_hierarchical, is_first_letter=True)
node_parent = {'id':node_id, 'children':[]}
category_node['children'].append(node_parent)
collapse_nodes.append(node_parent)
return collapse_letter, node_parent
def process_category_node(
category_node, items, category_data, eval_formatter, field_metadata,
opts, tag_map, hierarchical_tags, node_to_tag_map, collapse_nodes,
intermediate_nodes, hierarchical_items):
category = items[category_node['id']]['category']
category_items = category_data[category]
cat_len = len(category_items)
if cat_len <= 0:
return
collapse_letter = None
is_gst = items[category_node['id']].get('is_gst', False)
collapse_model = 'disable' if category in opts.dont_collapse else opts.collapse_model
fm = field_metadata[category]
category_child_map = {}
is_user_category = fm['kind'] == 'user' and not is_gst
top_level_component = 'z' + category_items[0].original_name
last_idx = -opts.collapse_at
category_is_hierarchical = (
category in opts.using_hierarchy and opts.sort_by == 'name' and
category not in {'authors', 'publisher', 'news', 'formats', 'rating'}
)
clear_rating = category not in categories_with_ratings and not fm['is_custom'] and not fm['kind'] == 'user'
collapsible = collapse_model != 'disable' and cat_len > opts.collapse_at
partitioned = collapse_model == 'partition'
cl_list = build_first_letter_list(category_items) if collapsible and collapse_model == 'first letter' else ()
node_parent = category_node
def create_tag_node(tag, parent):
# User categories contain references to items in other categories, so
# reflect that in the node structure as well.
node_data = tag_map.get(id(tag), None)
if node_data is None:
node_id = 'n%d' % len(tag_map)
node_data = items[node_id] = category_item_as_json(tag, clear_rating=clear_rating)
tag_map[id(tag)] = (node_id, node_data)
node_to_tag_map[node_id] = tag
else:
node_id, node_data = node_data
node = {'id':node_id, 'children':[]}
parent['children'].append(node)
return node, node_data
for idx, tag in enumerate(category_items):
if collapsible:
if partitioned:
last_idx, node_parent = collapse_partition(
collapse_nodes, items, category_node, idx, tag, opts, top_level_component,
cat_len, category_is_hierarchical, category_items,
eval_formatter, is_gst, last_idx, node_parent)
else: # by 'first letter'
collapse_letter, node_parent = collapse_first_letter(
collapse_nodes, items, category_node, cl_list, idx, is_gst, category_is_hierarchical, collapse_letter, node_parent)
else:
node_parent = category_node
tag_is_hierarchical = id(tag) in hierarchical_tags
components = get_name_components(tag.original_name) if category_is_hierarchical or tag_is_hierarchical else (tag.original_name,)
if not tag_is_hierarchical and (
is_user_category or not category_is_hierarchical or len(components) == 1 or
(fm['is_custom'] and fm['display'].get('is_names', False))
): # A non-hierarchical leaf item in a non-hierarchical category
node, item = create_tag_node(tag, node_parent)
category_child_map[item['name'], item['category']] = node
intermediate_nodes[tag.category, tag.original_name] = node
else:
orig_node_parent = node_parent
for i, component in enumerate(components):
if i == 0:
child_map = category_child_map
else:
child_map = {}
for sibling in node_parent['children']:
item = items[sibling['id']]
if not item.get('is_category', False):
child_map[item['name'], item['category']] = sibling
cm_key = component, tag.category
if cm_key in child_map:
node_parent = child_map[cm_key]
node_id = node_parent['id']
item = items[node_id]
item['is_hierarchical'] = 3 if tag.category == 'search' else 5
if tag.id_set is not None:
item['id_set'] |= tag.id_set
hierarchical_items.add(node_id)
hierarchical_tags.add(id(node_to_tag_map[node_parent['id']]))
else:
if i < len(components) - 1: # Non-leaf node
original_name = '.'.join(components[:i+1])
inode = intermediate_nodes.get((tag.category, original_name), None)
if inode is None:
t = copy(tag)
t.original_name, t.count = original_name, 0
t.is_editable, t.is_searchable = False, category == 'search'
node_parent, item = create_tag_node(t, node_parent)
hierarchical_tags.add(id(t))
intermediate_nodes[tag.category, original_name] = node_parent
else:
item = items[inode['id']]
ch = node_parent['children']
node_parent = {'id':inode['id'], 'children':[]}
ch.append(node_parent)
else:
node_parent, item = create_tag_node(tag, node_parent)
if not is_user_category:
item['original_name'] = tag.name
intermediate_nodes[tag.category, tag.original_name] = node_parent
item['name'] = component
item['is_hierarchical'] = 3 if tag.category == 'search' else 5
hierarchical_tags.add(id(tag))
child_map[cm_key] = node_parent
items[node_parent['id']]['id_set'] |= tag.id_set
node_parent = orig_node_parent
def iternode_descendants(node):
for child in node['children']:
yield child
yield from iternode_descendants(child)
def fillout_tree(root, items, node_id_map, category_nodes, category_data, field_metadata, opts, book_rating_map):
eval_formatter = EvalFormatter()
tag_map, hierarchical_tags, node_to_tag_map = {}, set(), {}
first, later, collapse_nodes, intermediate_nodes, hierarchical_items = [], [], [], {}, set()
# User categories have to be processed after normal categories as they can
# reference hierarchical nodes that were created only during processing of
# normal categories
for category_node_id in category_nodes:
cnode = items[category_node_id]
coll = later if cnode.get('is_user_category', False) else first
coll.append(node_id_map[category_node_id])
for coll in (first, later):
for cnode in coll:
process_category_node(
cnode, items, category_data, eval_formatter, field_metadata,
opts, tag_map, hierarchical_tags, node_to_tag_map,
collapse_nodes, intermediate_nodes, hierarchical_items)
# Do not store id_set in the tag items as it is a lot of data, with not
# much use. Instead only update the ratings and counts based on id_set
for item_id in hierarchical_items:
item = items[item_id]
total = count = 0
for book_id in item['id_set']:
rating = book_rating_map.get(book_id, 0)
if rating:
total += rating/2.0
count += 1
item['avg_rating'] = float(total)/count if count else 0
for item_id, item in itervalues(tag_map):
id_len = len(item.pop('id_set', ()))
if id_len:
item['count'] = id_len
for node in collapse_nodes:
item = items[node['id']]
item['count'] = sum(1 for _ in iternode_descendants(node))
def render_categories(opts, db, category_data):
items = {}
with db.safe_read_lock:
root, node_id_map, category_nodes, recount_nodes = create_toplevel_tree(category_data, items, db.field_metadata, opts)
fillout_tree(root, items, node_id_map, category_nodes, category_data, db.field_metadata, opts, db.fields['rating'].book_value_map)
for node in recount_nodes:
item = items[node['id']]
item['count'] = sum(1 for x in iternode_descendants(node) if not items[x['id']].get('is_category', False))
if opts.hidden_categories:
# We have to remove hidden categories after all processing is done as
# items from a hidden category could be in a user category
root['children'] = list(filter((lambda child:items[child['id']]['category'] not in opts.hidden_categories), root['children']))
if opts.hide_empty_categories:
root['children'] = list(filter((lambda child:items[child['id']]['count'] > 0), root['children']))
return {'root':root, 'item_map': items}
def categories_as_json(ctx, rd, db, opts, vl):
return ctx.get_tag_browser(rd, db, opts, partial(render_categories, opts), vl=vl)
# Test tag browser {{{
def dump_categories_tree(data):
root, items = data['root'], data['item_map']
ans, indent = [], ' '
def dump_node(node, level=0):
item = items[node['id']]
rating = item.get('avg_rating', None) or 0
if rating:
rating = ',rating=%.1f' % rating
try:
ans.append(indent*level + item['name'] + ' [count={}{}]'.format(item['count'], rating or ''))
except KeyError:
print(item)
raise
for child in node['children']:
dump_node(child, level+1)
if level == 0:
ans.append('')
[dump_node(c) for c in root['children']]
return '\n'.join(ans)
def dump_tags_model(m):
from qt.core import QModelIndex, Qt
ans, indent = [], ' '
def dump_node(index, level=-1):
if level > -1:
ans.append(indent*level + index.data(Qt.ItemDataRole.UserRole).dump_data())
for i in range(m.rowCount(index)):
dump_node(m.index(i, 0, index), level + 1)
if level == 0:
ans.append('')
dump_node(QModelIndex())
return '\n'.join(ans)
def test_tag_browser(library_path=None):
' Compare output of server and GUI tag browsers '
from calibre.library import db
olddb = db(library_path)
db = olddb.new_api
opts = categories_settings({}, db)
# opts = opts._replace(hidden_categories={'publisher'})
opts = opts._replace(hide_empty_categories=True)
category_data = db.get_categories(sort=opts.sort_by, first_letter_sort=opts.collapse_model == 'first letter')
data = render_categories(opts, db, category_data)
srv_data = dump_categories_tree(data)
from calibre.gui2 import Application, gprefs
from calibre.gui2.tag_browser.model import TagsModel
prefs = {
'tags_browser_category_icons':gprefs['tags_browser_category_icons'],
'tags_browser_collapse_at':opts.collapse_at,
'tags_browser_partition_method': opts.collapse_model,
'tag_browser_dont_collapse': opts.dont_collapse,
'tag_browser_hide_empty_categories': opts.hide_empty_categories,
}
app = Application([])
m = TagsModel(None, prefs)
m.set_database(olddb, opts.hidden_categories)
m_data = dump_tags_model(m)
if m_data == srv_data:
print('No differences found in the two Tag Browser implementations')
raise SystemExit(0)
from calibre.gui2.tweak_book.diff.main import Diff
d = Diff(show_as_window=True)
d.string_diff(m_data, srv_data, left_name='GUI', right_name='server')
d.exec()
del app
# }}}