Improvements to value icons:

1) Make average rating and count usable in templates
2) Pass the database to safe_format so it can be used in non-GUI contexts.
This commit is contained in:
Charles Haley 2025-01-20 18:02:12 +00:00
parent d1d2e18aaa
commit 4fa7f55516
6 changed files with 70 additions and 35 deletions

View File

@ -1083,6 +1083,8 @@ def evaluate(book, context):
tv = self.template_value tv = self.template_value
l = self.template_value.selectionModel().selectedRows() l = self.template_value.selectionModel().selectedRows()
break_on_mi = 0 if len(l) == 0 else l[0].row() break_on_mi = 0 if len(l) == 0 else l[0].row()
from calibre.gui2.ui import get_gui
db = get_gui().current_db
for r,mi in enumerate(self.mi): for r,mi in enumerate(self.mi):
w = tv.cellWidget(r, 0) w = tv.cellWidget(r, 0)
w.setText(mi.get('title', _('No title provided'))) w.setText(mi.get('title', _('No title provided')))
@ -1096,7 +1098,8 @@ def evaluate(book, context):
mi, global_vars=self.global_vars, mi, global_vars=self.global_vars,
template_functions=self.all_functions, template_functions=self.all_functions,
break_reporter=self.break_reporter if r == break_on_mi else None, break_reporter=self.break_reporter if r == break_on_mi else None,
python_context_object=self.python_context_object) python_context_object=self.python_context_object,
database=db)
w = tv.cellWidget(r, 2) w = tv.cellWidget(r, 2)
w.setText(v.translate(translate_table)) w.setText(v.translate(translate_table))
w.setCursorPosition(0) w.setCursorPosition(0)

View File

@ -55,6 +55,7 @@ class TagTreeItem: # {{{
icon_config_dir = {} icon_config_dir = {}
file_icon_provider = None file_icon_provider = None
eval_formatter = EvalFormatter() eval_formatter = EvalFormatter()
database = None
def __init__(self, data=None, is_category=False, icon_map=None, def __init__(self, data=None, is_category=False, icon_map=None,
parent=None, tooltip=None, category_key=None, temporary=False, parent=None, tooltip=None, category_key=None, temporary=False,
@ -143,9 +144,12 @@ class TagTreeItem: # {{{
if node.type != self.TAG or node.type == self.ROOT: if node.type != self.TAG or node.type == self.ROOT:
break break
if val_icon is None and TEMPLATE_ICON_INDICATOR in self.value_icons[category]: if val_icon is None and TEMPLATE_ICON_INDICATOR in self.value_icons[category]:
v = {'category': category, 'value': self.tag.original_name,
'count': getattr(self.tag, 'count', ''),
'avg_rating': getattr(self.tag, 'avg_rating', '')}
t = self.eval_formatter.safe_format(self.value_icons[category][TEMPLATE_ICON_INDICATOR][0], t = self.eval_formatter.safe_format(self.value_icons[category][TEMPLATE_ICON_INDICATOR][0],
{'category': category, 'value': self.tag.original_name}, v, 'VALUE_ICON_TEMPLATE_ERROR', {},
'VALUE_ICON_TEMPLATE_ERROR', {}) database=self.database)
if t: if t:
val_icon = (os.path.join('template_icons', t), False) val_icon = (os.path.join('template_icons', t), False)
else: else:
@ -406,8 +410,8 @@ class TagsModel(QAbstractItemModel): # {{{
self.filter_categories_by = None self.filter_categories_by = None
self.collapse_model = 'disable' self.collapse_model = 'disable'
self.row_map = [] self.row_map = []
self.root_item = self.create_node(icon_map=self.icon_state_map)
self.db = None self.db = None
self.root_item = self.create_node(icon_map=self.icon_state_map)
self._build_in_progress = False self._build_in_progress = False
self.reread_collapse_model({}, rebuild=False) self.reread_collapse_model({}, rebuild=False)
self.show_error_after_event_loop_tick_signal.connect(self.on_show_error_after_event_loop_tick, type=Qt.ConnectionType.QueuedConnection) self.show_error_after_event_loop_tick_signal.connect(self.on_show_error_after_event_loop_tick, type=Qt.ConnectionType.QueuedConnection)
@ -1483,6 +1487,7 @@ class TagsModel(QAbstractItemModel): # {{{
node.value_icons = self.value_icons node.value_icons = self.value_icons
node.value_icon_cache = self.value_icon_cache node.value_icon_cache = self.value_icon_cache
node.icon_config_dir = self.icon_config_dir node.icon_config_dir = self.icon_config_dir
node.database = self.db
return node return node
def get_node(self, idx): def get_node(self, idx):

View File

@ -686,14 +686,21 @@ class TagsView(QTreeView): # {{{
if action == 'set_icon': if action == 'set_icon':
if category is None: if category is None:
if index is not None: if index is not None:
current_item = self._model.get_node(index).tag.original_name tag = self._model.get_node(index).tag
current_item = tag.original_name
count = tag.count
avg_rating = tag.avg_rating
else: else:
current_item = _('No value available') current_item = _('No value available')
count = ''
avg_rating = ''
template = self._model.value_icons.get(key, {}).get(TEMPLATE_ICON_INDICATOR, ('', False))[0] template = self._model.value_icons.get(key, {}).get(TEMPLATE_ICON_INDICATOR, ('', False))[0]
from calibre.gui2.dialogs.template_dialog import TemplateDialog from calibre.gui2.dialogs.template_dialog import TemplateDialog
from calibre.utils.formatter import EvalFormatter from calibre.utils.formatter import EvalFormatter
v = {'title': key, 'category': key, 'value': current_item,
'count': count, 'avg_rating': avg_rating}
d = TemplateDialog(parent=self, text=template, d = TemplateDialog(parent=self, text=template,
mi={'title': key, 'category': key, 'value': current_item}, mi=v,
doing_emblem=True, doing_emblem=True,
# fm=None, color_field=None, icon_field_key=None, # fm=None, color_field=None, icon_field_key=None,
# icon_rule_kind=None, text_is_placeholder=False, # icon_rule_kind=None, text_is_placeholder=False,

View File

@ -166,7 +166,8 @@ def get_gpref(name: str, defval = None):
return gprefs.get(name, defval) return gprefs.get(name, defval)
def get_icon_for_node(node, parent, node_to_tag_map, tag_map, eval_formatter): def get_icon_for_node(node, parent, node_to_tag_map, tag_map, eval_formatter, db):
# This needs a legacy database so legacy formatter functions work
category = node['category'] category = node['category']
if category in ('search', 'formats') or category.startswith('@'): if category in ('search', 'formats') or category.startswith('@'):
return return
@ -190,10 +191,13 @@ def get_icon_for_node(node, parent, node_to_tag_map, tag_map, eval_formatter):
if val_icon is not None and for_children: if val_icon is not None and for_children:
break break
par = pd par = pd
val_icon = None
if val_icon is None and TEMPLATE_ICON_INDICATOR in value_icons.get(category, {}): if val_icon is None and TEMPLATE_ICON_INDICATOR in value_icons.get(category, {}):
v = {'category': category, 'value': name_for_icon(node),
'count': node.get('count', ''), 'avg_rating': node.get('avg_rating', '')}
t = eval_formatter.safe_format( t = eval_formatter.safe_format(
value_icons[category][TEMPLATE_ICON_INDICATOR][0], {'category': category, 'value': name_for_icon(node)}, value_icons[category][TEMPLATE_ICON_INDICATOR][0], v,
'VALUE_ICON_TEMPLATE_ERROR', {}) 'VALUE_ICON_TEMPLATE_ERROR', {}, database=db)
if t: if t:
# Use POSIX path separator # Use POSIX path separator
val_icon = 'template_icons/' + t val_icon = 'template_icons/' + t
@ -428,7 +432,7 @@ def collapse_first_letter(collapse_nodes, items, category_node, cl_list, idx, is
def process_category_node( def process_category_node(
category_node, items, category_data, eval_formatter, field_metadata, category_node, items, category_data, eval_formatter, field_metadata,
opts, tag_map, hierarchical_tags, node_to_tag_map, collapse_nodes, opts, tag_map, hierarchical_tags, node_to_tag_map, collapse_nodes,
intermediate_nodes, hierarchical_items): intermediate_nodes, hierarchical_items, db):
category = items[category_node['id']]['category'] category = items[category_node['id']]['category']
if category not in category_data: if category not in category_data:
# This can happen for user categories that are hierarchical and missing their parent. # This can happen for user categories that are hierarchical and missing their parent.
@ -469,7 +473,7 @@ def process_category_node(
node = {'id':node_id, 'children':[]} node = {'id':node_id, 'children':[]}
parent['children'].append(node) parent['children'].append(node)
try: try:
get_icon_for_node(node_data, parent, node_to_tag_map, tag_map, eval_formatter) get_icon_for_node(node_data, parent, node_to_tag_map, tag_map, eval_formatter, db)
except Exception: except Exception:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
@ -555,7 +559,13 @@ def iternode_descendants(node):
yield from iternode_descendants(child) yield from iternode_descendants(child)
def fillout_tree(root, items, node_id_map, category_nodes, category_data, field_metadata, opts, book_rating_map): def fillout_tree(root, items, node_id_map, category_nodes, category_data, field_metadata, opts, book_rating_map, db):
# Convert the DB to an old DB if needed, which it seems to be
wr = db.new_api.library_database_instance
if wr is not None:
db = wr()
else: # This shouldn't happen, but
db = None
eval_formatter = EvalFormatter() eval_formatter = EvalFormatter()
tag_map, hierarchical_tags, node_to_tag_map = {}, set(), {} tag_map, hierarchical_tags, node_to_tag_map = {}, set(), {}
first, later, collapse_nodes, intermediate_nodes, hierarchical_items = [], [], [], {}, set() first, later, collapse_nodes, intermediate_nodes, hierarchical_items = [], [], [], {}, set()
@ -572,7 +582,7 @@ def fillout_tree(root, items, node_id_map, category_nodes, category_data, field_
process_category_node( process_category_node(
cnode, items, category_data, eval_formatter, field_metadata, cnode, items, category_data, eval_formatter, field_metadata,
opts, tag_map, hierarchical_tags, node_to_tag_map, opts, tag_map, hierarchical_tags, node_to_tag_map,
collapse_nodes, intermediate_nodes, hierarchical_items) collapse_nodes, intermediate_nodes, hierarchical_items, db)
# Do not store id_set in the tag items as it is a lot of data, with not # 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 # much use. Instead only update the ratings and counts based on id_set
@ -600,7 +610,7 @@ def render_categories(opts, db, category_data):
items = {} items = {}
with db.safe_read_lock: with db.safe_read_lock:
root, node_id_map, category_nodes, recount_nodes = create_toplevel_tree(category_data, items, db.field_metadata, opts, db) root, node_id_map, category_nodes, recount_nodes = create_toplevel_tree(category_data, items, db.field_metadata, opts, db)
fillout_tree(root, items, node_id_map, category_nodes, category_data, db.field_metadata, opts, db.fields['rating'].book_value_map) fillout_tree(root, items, node_id_map, category_nodes, category_data, db.field_metadata, opts, db.fields['rating'].book_value_map, db)
for node in recount_nodes: for node in recount_nodes:
item = items[node['id']] item = items[node['id']]
item['count'] = sum(1 for x in iternode_descendants(node) if not items[x['id']].get('is_category', False)) item['count'] = sum(1 for x in iternode_descendants(node) if not items[x['id']].get('is_category', False))

View File

@ -1651,6 +1651,7 @@ class TemplateFormatter(string.Formatter):
self.recursion_level = -1 self.recursion_level = -1
self._caller = None self._caller = None
self.python_context_object = None self.python_context_object = None
self.database = None
def _do_format(self, val, fmt): def _do_format(self, val, fmt):
if not fmt or not val: if not fmt or not val:
@ -1759,7 +1760,8 @@ class TemplateFormatter(string.Formatter):
def _run_python_template(self, compiled_template, arguments): def _run_python_template(self, compiled_template, arguments):
try: try:
self.python_context_object.set_values( self.python_context_object.set_values(
db=get_database(self.book, get_database(self.book, None)), db=(self.database if self.database is not None
else get_database(self.book, get_database(self.book, None))),
globals=self.global_vars, globals=self.global_vars,
arguments=arguments, arguments=arguments,
formatter=self, formatter=self,
@ -1914,7 +1916,8 @@ class TemplateFormatter(string.Formatter):
self.funcs, self.funcs,
self.locals, self.locals,
self._caller, self._caller,
self.python_context_object)) self.python_context_object,
self.database))
def restore_state(self, state): def restore_state(self, state):
self.recursion_level -= 1 self.recursion_level -= 1
@ -1929,7 +1932,8 @@ class TemplateFormatter(string.Formatter):
self.funcs, self.funcs,
self.locals, self.locals,
self._caller, self._caller,
self.python_context_object) = state self.python_context_object,
self.database) = state
# Allocate an interpreter if the formatter encounters a GPM or TPM template. # Allocate an interpreter if the formatter encounters a GPM or TPM template.
# We need to allocate additional interpreters if there is composite recursion # We need to allocate additional interpreters if there is composite recursion
@ -1980,12 +1984,13 @@ class TemplateFormatter(string.Formatter):
column_name=None, template_cache=None, column_name=None, template_cache=None,
strip_results=True, template_functions=None, strip_results=True, template_functions=None,
global_vars=None, break_reporter=None, global_vars=None, break_reporter=None,
python_context_object=None): python_context_object=None, database=None):
state = self.save_state() state = self.save_state()
if self.recursion_level == 0: if self.recursion_level == 0:
# Initialize the composite values dict if this is the base-level # Initialize the composite values dict and database if this is the
# call. Recursive calls will use the same dict. # base-level call. Recursive calls will use the same dict.
self.composite_values = {} self.composite_values = {}
self.database = database
try: try:
self._caller = FormatterFuncsCaller(self) self._caller = FormatterFuncsCaller(self)
self.strip_results = strip_results self.strip_results = strip_results

View File

@ -248,7 +248,12 @@ class FormatterFunction:
def only_in_gui_error(self): def only_in_gui_error(self):
only_in_gui_error(self.name) only_in_gui_error(self.name)
def get_database(self, mi): def get_database(self, mi, formatter=None):
if formatter is not None:
if hasattr(formatter, 'database'):
db = formatter.database
if db is not None:
return db
return get_database(mi, self.name) return get_database(mi, self.name)
@ -1680,7 +1685,7 @@ attached to the current book.[/] This function works only in the GUI.
''') ''')
def evaluate(self, formatter, kwargs, mi, locals): def evaluate(self, formatter, kwargs, mi, locals):
c = self.get_database(mi).new_api.annotation_count_for_book(mi.id) c = self.get_database(mi, formatter=formatter).new_api.annotation_count_for_book(mi.id)
return '' if c == 0 else str(c) return '' if c == 0 else str(c)
@ -1697,7 +1702,7 @@ not marked. This function works only in the GUI.
''') ''')
def evaluate(self, formatter, kwargs, mi, locals): def evaluate(self, formatter, kwargs, mi, locals):
c = self.get_database(mi).data.get_marked(mi.id) c = self.get_database(mi, formatter=formatter).data.get_marked(mi.id)
return c if c else '' return c if c else ''
@ -2346,7 +2351,7 @@ and use that column's value in your save/send templates.
''') ''')
def evaluate(self, formatter, kwargs, mi, locals_): def evaluate(self, formatter, kwargs, mi, locals_):
db = self.get_database(mi) db = self.get_database(mi, formatter=formatter)
try: try:
a = db.data.get_virtual_libraries_for_books((mi.id,)) a = db.data.get_virtual_libraries_for_books((mi.id,))
return ', '.join(a[mi.id]) return ', '.join(a[mi.id])
@ -2370,7 +2375,7 @@ This function works only in the GUI.
''') ''')
def evaluate(self, formatter, kwargs, mi, locals): def evaluate(self, formatter, kwargs, mi, locals):
return self.get_database(mi).data.get_base_restriction_name() return self.get_database(mi, formatter=formatter).data.get_base_restriction_name()
class BuiltinUserCategories(BuiltinFormatterFunction): class BuiltinUserCategories(BuiltinFormatterFunction):
@ -2441,7 +2446,7 @@ ans
''') ''')
def evaluate(self, formatter, kwargs, mi, locals, field_name, field_value): def evaluate(self, formatter, kwargs, mi, locals, field_name, field_value):
db = self.get_database(mi).new_api db = self.get_database(mi, formatter=formatter).new_api
try: try:
link = None link = None
item_id = db.get_item_id(field_name, field_value, case_sensitive=True) item_id = db.get_item_id(field_name, field_value, case_sensitive=True)
@ -2602,7 +2607,7 @@ More than one of ``is_undefined``, ``is_false``, or ``is_true`` can be set to 1.
def evaluate(self, formatter, kwargs, mi, locals, field, is_undefined, is_false, is_true): def evaluate(self, formatter, kwargs, mi, locals, field, is_undefined, is_false, is_true):
# 'field' is a lookup name, not a value # 'field' is a lookup name, not a value
if field not in self.get_database(mi).field_metadata: if field not in self.get_database(mi, formatter=formatter).field_metadata:
raise ValueError(_("The column {} doesn't exist").format(field)) raise ValueError(_("The column {} doesn't exist").format(field))
res = getattr(mi, field, None) res = getattr(mi, field, None)
if res is None: if res is None:
@ -2865,7 +2870,7 @@ expressions.
if (not tweaks.get('allow_template_database_functions_in_composites', False) and if (not tweaks.get('allow_template_database_functions_in_composites', False) and
formatter.global_vars.get(rendering_composite_name, None)): formatter.global_vars.get(rendering_composite_name, None)):
raise ValueError(_('The book_count() function cannot be used in a composite column')) raise ValueError(_('The book_count() function cannot be used in a composite column'))
db = self.get_database(mi) db = self.get_database(mi, formatter=formatter)
try: try:
ids = db.search_getting_ids(query, None, use_virtual_library=use_vl != '0') ids = db.search_getting_ids(query, None, use_virtual_library=use_vl != '0')
return len(ids) return len(ids)
@ -2895,7 +2900,7 @@ used only in the GUI.
if (not tweaks.get('allow_template_database_functions_in_composites', False) and if (not tweaks.get('allow_template_database_functions_in_composites', False) and
formatter.global_vars.get(rendering_composite_name, None)): formatter.global_vars.get(rendering_composite_name, None)):
raise ValueError(_('The book_values() function cannot be used in a composite column')) raise ValueError(_('The book_values() function cannot be used in a composite column'))
db = self.get_database(mi) db = self.get_database(mi, formatter=formatter)
if column not in db.field_metadata: if column not in db.field_metadata:
raise ValueError(_("The column {} doesn't exist").format(column)) raise ValueError(_("The column {} doesn't exist").format(column))
try: try:
@ -2930,7 +2935,7 @@ This function can be used only in the GUI.
if len(args) > 1: if len(args) > 1:
raise ValueError(_('Incorrect number of arguments for function {0}').format('has_extra_files')) raise ValueError(_('Incorrect number of arguments for function {0}').format('has_extra_files'))
pattern = args[0] if len(args) == 1 else None pattern = args[0] if len(args) == 1 else None
db = self.get_database(mi).new_api db = self.get_database(mi, formatter=formatter).new_api
try: try:
files = tuple(f.relpath.partition('/')[-1] for f in files = tuple(f.relpath.partition('/')[-1] for f in
db.list_extra_files(mi.id, use_cache=True, pattern=DATA_FILE_PATTERN)) db.list_extra_files(mi.id, use_cache=True, pattern=DATA_FILE_PATTERN))
@ -2961,7 +2966,7 @@ the functions :ref:`has_extra_files`, :ref:`extra_file_modtime` and
if len(args) > 1: if len(args) > 1:
raise ValueError(_('Incorrect number of arguments for function {0}').format('has_extra_files')) raise ValueError(_('Incorrect number of arguments for function {0}').format('has_extra_files'))
pattern = args[0] if len(args) == 1 else None pattern = args[0] if len(args) == 1 else None
db = self.get_database(mi).new_api db = self.get_database(mi, formatter=formatter).new_api
try: try:
files = tuple(f.relpath.partition('/')[-1] for f in files = tuple(f.relpath.partition('/')[-1] for f in
db.list_extra_files(mi.id, use_cache=True, pattern=DATA_FILE_PATTERN)) db.list_extra_files(mi.id, use_cache=True, pattern=DATA_FILE_PATTERN))
@ -2987,7 +2992,7 @@ also the functions :ref:`has_extra_files`, :ref:`extra_file_names` and
''') ''')
def evaluate(self, formatter, kwargs, mi, locals, file_name): def evaluate(self, formatter, kwargs, mi, locals, file_name):
db = self.get_database(mi).new_api db = self.get_database(mi, formatter=formatter).new_api
try: try:
q = posixpath.join(DATA_DIR_NAME, file_name) q = posixpath.join(DATA_DIR_NAME, file_name)
for f in db.list_extra_files(mi.id, use_cache=True, pattern=DATA_FILE_PATTERN): for f in db.list_extra_files(mi.id, use_cache=True, pattern=DATA_FILE_PATTERN):
@ -3016,7 +3021,7 @@ This function can be used only in the GUI.
''') ''')
def evaluate(self, formatter, kwargs, mi, locals, file_name, format_string): def evaluate(self, formatter, kwargs, mi, locals, file_name, format_string):
db = self.get_database(mi).new_api db = self.get_database(mi, formatter=formatter).new_api
try: try:
q = posixpath.join(DATA_DIR_NAME, file_name) q = posixpath.join(DATA_DIR_NAME, file_name)
for f in db.list_extra_files(mi.id, use_cache=True, pattern=DATA_FILE_PATTERN): for f in db.list_extra_files(mi.id, use_cache=True, pattern=DATA_FILE_PATTERN):
@ -3057,7 +3062,7 @@ program:
''') ''')
def evaluate(self, formatter, kwargs, mi, locals, field_name, field_value, plain_text): def evaluate(self, formatter, kwargs, mi, locals, field_name, field_value, plain_text):
db = self.get_database(mi).new_api db = self.get_database(mi, formatter=formatter).new_api
try: try:
note = None note = None
item_id = db.get_item_id(field_name, field_value, case_sensitive=True) item_id = db.get_item_id(field_name, field_value, case_sensitive=True)
@ -3125,7 +3130,7 @@ values in ``field_name``. Example:
''') ''')
def evaluate(self, formatter, kwargs, mi, locals, field_name, field_value): def evaluate(self, formatter, kwargs, mi, locals, field_name, field_value):
db = self.get_database(mi).new_api db = self.get_database(mi, formatter=formatter).new_api
if field_value: if field_value:
note = None note = None
try: try: