| Server IP : 35.80.110.71 / Your IP : 216.73.216.221 Web Server : Apache/2.4.58 (Ubuntu) System : Linux ip-172-31-21-44 6.17.0-1019-aws #19~24.04.1-Ubuntu SMP Tue Jun 23 18:53:06 UTC 2026 x86_64 User : ubuntu ( 1000) PHP Version : 8.3.31 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /lib/calibre/calibre/gui2/tag_browser/ |
Upload File : |
#!/usr/bin/env python3
__license__ = 'GPL v3'
__copyright__ = '2011, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
import copy
import os
import traceback
from collections import OrderedDict, namedtuple
from qt.core import (
QAbstractItemModel, QFont, QIcon, QMimeData, QModelIndex, QObject, Qt, pyqtSignal,
)
from calibre.constants import config_dir
from calibre.db.categories import Tag, category_display_order
from calibre.ebooks.metadata import rating_to_stars
from calibre.gui2 import (
config, error_dialog, file_icon_provider, gprefs, question_dialog,
)
from calibre.gui2.dialogs.confirm_delete import confirm
from calibre.library.field_metadata import category_icon_map
from calibre.utils.config import prefs, tweaks
from calibre.utils.formatter import EvalFormatter
from calibre.utils.icu import (
collation_order_for_partitioning, contains, lower, lower as icu_lower,
primary_contains, primary_strcmp, sort_key, strcmp, upper as icu_upper,
)
from calibre.utils.serialize import json_dumps, json_loads
from polyglot.builtins import iteritems, itervalues
TAG_SEARCH_STATES = {'clear': 0, 'mark_plus': 1, 'mark_plusplus': 2,
'mark_minus': 3, 'mark_minusminus': 4}
DRAG_IMAGE_ROLE = Qt.ItemDataRole.UserRole + 1000
COUNT_ROLE = DRAG_IMAGE_ROLE + 1
_bf = None
def bf():
global _bf
if _bf is None:
_bf = QFont()
_bf.setBold(True)
_bf = (_bf)
return _bf
class TagTreeItem: # {{{
CATEGORY = 0
TAG = 1
ROOT = 2
category_custom_icons = {}
file_icon_provider = None
def __init__(self, data=None, is_category=False, icon_map=None,
parent=None, tooltip=None, category_key=None, temporary=False,
is_gst=False):
if self.file_icon_provider is None:
self.file_icon_provider = TagTreeItem.file_icon_provider = file_icon_provider().icon_from_ext
self.parent = parent
self.children = []
self.blank = QIcon()
self.is_gst = is_gst
self.boxed = False
self.temporary = False
self.can_be_edited = False
self.icon_state_map = list(icon_map)
if self.parent is not None:
self.parent.append(self)
if data is None:
self.type = self.ROOT
else:
self.type = self.CATEGORY if is_category else self.TAG
if self.type == self.CATEGORY:
self.name = data
self.py_name = data
self.category_key = category_key
self.temporary = temporary
self.tag = Tag(data, category=category_key,
is_editable=category_key not in
['news', 'search', 'identifiers', 'languages'],
is_searchable=category_key not in ['search'])
elif self.type == self.TAG:
self.tag = data
self.cached_average_rating = None
self.cached_item_count = None
self.tooltip = tooltip or ''
@property
def name_id(self):
if self.type == self.CATEGORY:
return self.category_key + ':' + self.name
elif self.type == self.TAG:
return self.tag.original_name
return ''
def break_cycles(self):
del self.parent
del self.children
def root_node(self):
p = self
while p.parent.type != self.ROOT:
p = p.parent
return p
def ensure_icon(self):
if self.icon_state_map[0] is not None:
return
if self.type == self.TAG:
if self.tag.category == 'formats':
fmt = self.tag.original_name.replace('ORIGINAL_', '')
cc = self.file_icon_provider(fmt)
else:
if self.is_gst:
cc = self.category_custom_icons.get(self.root_node().category_key, None)
elif self.tag.category == 'search' and not self.tag.is_searchable:
cc = self.category_custom_icons.get('search_folder:', None)
else:
cc = self.category_custom_icons.get(self.tag.category, None)
elif self.type == self.CATEGORY:
cc = self.category_custom_icons.get(self.category_key, None)
self.icon_state_map[0] = cc or QIcon()
def __str__(self):
if self.type == self.ROOT:
return 'ROOT'
if self.type == self.CATEGORY:
return 'CATEGORY(category_key={!r}, name={!r}, num_children={!r}, temp={!r})'.format(
self.category_key, self.name, len(self.children), self.temporary)
return f'TAG(name={self.tag.name!r}), temp={self.temporary!r})'
def row(self):
if self.parent is not None:
return self.parent.children.index(self)
return 0
def append(self, child):
child.parent = self
self.children.append(child)
@property
def average_rating(self):
if self.type != self.TAG:
return 0
if self.tag.category == 'search':
return None
if not self.tag.is_hierarchical:
return self.tag.avg_rating
if not self.children:
return self.tag.avg_rating # leaf node, avg_rating is correct
if self.cached_average_rating is None:
raise ValueError('Must compute average rating for tag ' + self.tag.original_name)
return self.cached_average_rating
@property
def item_count(self):
if not self.tag.is_hierarchical or not self.children:
return self.tag.count
if self.cached_item_count is not None:
return self.cached_item_count
def child_item_set(node):
s = node.tag.id_set.copy()
for child in node.children:
s |= child_item_set(child)
return s
self.cached_item_count = len(child_item_set(self))
return self.cached_item_count
def data(self, role):
if role == Qt.ItemDataRole.UserRole:
return self
if self.type == self.TAG:
return self.tag_data(role)
if self.type == self.CATEGORY:
return self.category_data(role)
return None
def category_data(self, role):
if role == Qt.ItemDataRole.DisplayRole:
return self.py_name
if role == Qt.ItemDataRole.EditRole:
return (self.py_name)
if role == Qt.ItemDataRole.DecorationRole:
if not self.tag.state:
self.ensure_icon()
return self.icon_state_map[self.tag.state]
if role == Qt.ItemDataRole.FontRole:
return bf()
if role == Qt.ItemDataRole.ToolTipRole:
return self.tooltip if gprefs['tag_browser_show_tooltips'] else None
if role == DRAG_IMAGE_ROLE:
self.ensure_icon()
return self.icon_state_map[0]
if role == COUNT_ROLE:
return len(self.child_tags())
return None
def tag_data(self, role):
tag = self.tag
if tag.use_sort_as_name:
name = tag.sort
else:
if not tag.is_hierarchical:
name = tag.original_name
else:
name = tag.name
if role == Qt.ItemDataRole.DisplayRole:
return str(name)
if role == Qt.ItemDataRole.EditRole:
return (tag.original_name)
if role == Qt.ItemDataRole.DecorationRole:
if not tag.state:
self.ensure_icon()
return self.icon_state_map[tag.state]
if role == Qt.ItemDataRole.ToolTipRole:
if gprefs['tag_browser_show_tooltips']:
if self.type == self.TAG and tag.category == 'search':
if tag.search_expression is None:
return _('{} is not a saved search').format(tag.original_name)
return (f'search:{tag.original_name}\n' +
_('Search expression:') + ' ' + tag.search_expression)
tt = [self.tooltip] if self.tooltip else []
if tag.original_categories:
tt.append('{}:{}'.format(','.join(tag.original_categories), tag.original_name))
else:
tt.append(f'{tag.category}:{tag.original_name}')
ar = self.average_rating
if ar:
tt.append(_('Average rating for books in this category: %.1f') % ar)
elif self.type == self.TAG:
if ar is not None:
tt.append(_('Books in this category are unrated'))
if tag.category != 'search':
tt.append(_('Number of books: %s') % self.item_count)
from calibre.gui2.ui import get_gui
db = get_gui().current_db.new_api
link = (None if not db.has_link_map(tag.category)
else db.get_link_map(tag.category).get(tag.original_name))
if link:
tt.append(_('Link: %s') % link)
return '\n'.join(tt)
return None
if role == DRAG_IMAGE_ROLE:
self.ensure_icon()
return self.icon_state_map[0]
if role == COUNT_ROLE:
return self.item_count
return None
def dump_data(self):
fmt = '%s [count=%s%s]'
if self.type == self.CATEGORY:
return fmt % (self.py_name, len(self.child_tags()), '')
tag = self.tag
if tag.use_sort_as_name:
name = tag.sort
else:
if not tag.is_hierarchical:
name = tag.original_name
else:
name = tag.name
count = self.item_count
rating = self.average_rating
if rating:
rating = ',rating=%.1f' % rating
return fmt % (name, count, rating or '')
def toggle(self, set_to=None):
'''
set_to: None => advance the state, otherwise a value from TAG_SEARCH_STATES
'''
tag = self.tag
if set_to is None:
while True:
tag_search_order_graph = gprefs.get('tb_search_order')
# JSON dumps converts integer keys to strings, so do it explicitly
tag.state = tag_search_order_graph[str(tag.state)]
if tag.state == TAG_SEARCH_STATES['mark_plus'] or \
tag.state == TAG_SEARCH_STATES['mark_minus']:
if tag.is_searchable:
break
elif tag.state == TAG_SEARCH_STATES['mark_plusplus'] or\
tag.state == TAG_SEARCH_STATES['mark_minusminus']:
if tag.is_searchable and len(self.children) and \
tag.is_hierarchical == '5state':
break
else:
break
else:
tag.state = set_to
def all_children(self):
res = []
def recurse(nodes, res):
for t in nodes:
res.append(t)
recurse(t.children, res)
recurse(self.children, res)
return res
def child_tags(self):
res = []
def recurse(nodes, res, depth):
if depth > 100:
return
for t in nodes:
if t.type != TagTreeItem.CATEGORY:
res.append(t)
recurse(t.children, res, depth+1)
recurse(self.children, res, 1)
return res
# }}}
FL_Interval = namedtuple('FL_Interval', ('first_chr', 'last_chr', 'length'))
def rename_only_in_vl_question(parent):
return question_dialog(parent,
_('Rename in Virtual library'), '<p>' +
_('Do you want this rename to apply only to books '
'in the current Virtual library?') + '</p>',
yes_text=_('Yes, apply only in VL'),
no_text=_('No, apply in entire library'))
class TagsModel(QAbstractItemModel): # {{{
search_item_renamed = pyqtSignal()
tag_item_renamed = pyqtSignal()
refresh_required = pyqtSignal()
research_required = pyqtSignal()
restriction_error = pyqtSignal(object)
drag_drop_finished = pyqtSignal(object)
user_categories_edited = pyqtSignal(object, object)
user_category_added = pyqtSignal()
show_error_after_event_loop_tick_signal = pyqtSignal(object, object, object)
convert_requested = pyqtSignal(object, object)
def __init__(self, parent, prefs=gprefs):
QAbstractItemModel.__init__(self, parent)
self.use_position_based_index_on_next_recount = False
self.prefs = prefs
self.node_map = {}
self.category_nodes = []
self.category_custom_icons = {}
for k, v in iteritems(self.prefs['tags_browser_category_icons']):
icon = QIcon(os.path.join(config_dir, 'tb_icons', v))
if len(icon.availableSizes()) > 0:
self.category_custom_icons[k] = icon
self.categories_with_ratings = ['authors', 'series', 'publisher', 'tags']
self.icon_state_map = [None, QIcon.ic('plus.png'), QIcon.ic('plusplus.png'),
QIcon.ic('minus.png'), QIcon.ic('minusminus.png')]
self.hidden_categories = set()
self.search_restriction = None
self.filter_categories_by = None
self.collapse_model = 'disable'
self.row_map = []
self.root_item = self.create_node(icon_map=self.icon_state_map)
self.db = None
self._build_in_progress = 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.reset_notes_and_link_maps()
@property
def gui_parent(self):
return QObject.parent(self)
def rename_user_category_icon(self, old_key, new_key):
'''
This is required for user categories because the key (lookup name) changes
on rename. We must rename the old icon to use the new key then update
the preferences and internal tables.
'''
old_icon = self.prefs['tags_browser_category_icons'].get(old_key, None)
if old_icon is not None:
old_path = os.path.join(config_dir, 'tb_icons', old_icon)
_, ext = os.path.splitext(old_path)
new_icon = new_key + ext
new_path = os.path.join(config_dir, 'tb_icons', new_icon)
os.replace(old_path, new_path)
self.set_custom_category_icon(new_key, new_path)
self.set_custom_category_icon(old_key, None)
def set_custom_category_icon(self, key, path):
d = self.prefs['tags_browser_category_icons']
if path:
d[key] = path
self.category_custom_icons[key] = QIcon(os.path.join(config_dir,
'tb_icons', path))
else:
if key in d:
path = os.path.join(config_dir, 'tb_icons', d[key])
try:
os.remove(path)
except:
pass
del d[key]
del self.category_custom_icons[key]
self.prefs['tags_browser_category_icons'] = d
def reread_collapse_model(self, state_map, rebuild=True):
if self.prefs['tags_browser_collapse_at'] == 0:
self.collapse_model = 'disable'
else:
self.collapse_model = self.prefs['tags_browser_partition_method']
if rebuild:
self.rebuild_node_tree(state_map)
def set_database(self, db, hidden_categories=None):
self.beginResetModel()
hidden_cats = db.new_api.pref('tag_browser_hidden_categories', None)
# migrate from config to db prefs
if hidden_cats is None:
hidden_cats = config['tag_browser_hidden_categories']
self.hidden_categories = set()
# strip out any non-existent field keys
for cat in hidden_cats:
if cat in db.field_metadata:
self.hidden_categories.add(cat)
db.new_api.set_pref('tag_browser_hidden_categories', list(self.hidden_categories))
if hidden_categories is not None:
self.hidden_categories = hidden_categories
self.db = db
self._run_rebuild()
self.endResetModel()
def reset_tag_browser(self):
self.beginResetModel()
hidden_cats = self.db.new_api.pref('tag_browser_hidden_categories', {})
self.hidden_categories = set()
# strip out any non-existent field keys
for cat in hidden_cats:
if cat in self.db.field_metadata:
self.hidden_categories.add(cat)
self._run_rebuild()
self.endResetModel()
def _cached_notes_map(self, category):
if self.notes_map is None:
self.notes_map = {}
ans = self.notes_map.get(category)
if ans is None:
try:
self.notes_map[category] = ans = (self.db.new_api.get_all_items_that_have_notes(category),
self.db.new_api.get_item_name_map(category))
except Exception:
self.notes_map[category] = ans = (frozenset(), {})
return ans
def _cached_link_map(self, category):
if self.link_map is None:
self.link_map = {}
ans = self.link_map.get(category)
if ans is None:
try:
self.link_map[category] = ans = self.db.new_api.get_link_map(category)
except Exception:
self.link_map[category] = ans = {}
return ans
def category_has_notes(self, category):
return bool(self._cached_notes_map(category)[0])
def item_has_note(self, category, item_name):
notes_map, item_id_map = self._cached_notes_map(category)
return item_id_map.get(item_name) in notes_map
def category_has_links(self, category):
return bool(self._cached_link_map(category))
def item_has_link(self, category, item_name):
return item_name in self._cached_link_map(category)
def reset_notes_and_link_maps(self):
self.link_map = self.notes_map = None
def rebuild_node_tree(self, state_map={}):
if self._build_in_progress:
print('Tag browser build already in progress')
traceback.print_stack()
return
# traceback.print_stack()
# print ()
self._build_in_progress = True
self.beginResetModel()
self._run_rebuild(state_map=state_map)
self.endResetModel()
self._build_in_progress = False
def _run_rebuild(self, state_map={}):
self.reset_notes_and_link_maps()
for node in itervalues(self.node_map):
node.break_cycles()
del node # Clear reference to node in the current frame
self.node_map.clear()
self.category_nodes = []
self.hierarchical_categories = {}
self.root_item = self.create_node(icon_map=self.icon_state_map)
self._rebuild_node_tree(state_map=state_map)
def _rebuild_node_tree(self, state_map):
# Note that _get_category_nodes can indirectly change the
# user_categories dict.
data = self._get_category_nodes(config['sort_tags_by'])
gst = self.db.new_api.pref('grouped_search_terms', {})
if self.category_custom_icons.get('search_folder:', None) is None:
self.category_custom_icons['search_folder:'] = QIcon.ic('folder_saved_search')
last_category_node = None
category_node_map = {}
self.user_category_node_tree = {}
# We build the node tree including categories that might later not be
# displayed because their items might be in User categories. The resulting
# nodes will be reordered later.
for i, key in enumerate(self.categories):
is_gst = False
if key.startswith('@') and key[1:] in gst:
tt = _('The grouped search term name is "{0}"').format(key)
is_gst = True
elif key == 'news':
tt = ''
else:
cust_desc = ''
fm = self.db.field_metadata[key]
if fm['is_custom']:
cust_desc = fm['display'].get('description', '')
if cust_desc:
cust_desc = '\n' + _('Description:') + ' ' + cust_desc
tt = _('The lookup/search name is "{0}"{1}').format(key, cust_desc)
if self.category_custom_icons.get(key, None) is None:
self.category_custom_icons[key] = QIcon.ic(
category_icon_map['gst'] if is_gst else category_icon_map.get(
key, (category_icon_map['user:'] if key.startswith('@') else category_icon_map['custom:'])))
if key.startswith('@'):
path_parts = [p for p in key.split('.')]
path = ''
last_category_node = self.root_item
tree_root = self.user_category_node_tree
for i,p in enumerate(path_parts):
path += p
if path not in category_node_map:
node = self.create_node(parent=last_category_node,
data=p[1:] if i == 0 else p,
is_category=True,
is_gst=is_gst,
tooltip=tt if path == key else path,
category_key=path,
icon_map=self.icon_state_map)
last_category_node = node
category_node_map[path] = node
self.category_nodes.append(node)
node.can_be_edited = (not is_gst) and (i == (len(path_parts)-1))
if not is_gst:
node.tag.is_hierarchical = '5state'
tree_root[p] = {}
tree_root = tree_root[p]
else:
last_category_node = category_node_map[path]
tree_root = tree_root[p]
path += '.'
else:
node = self.create_node(parent=self.root_item,
data=self.categories[key],
is_category=True,
is_gst=False,
tooltip=tt, category_key=key,
icon_map=self.icon_state_map)
category_node_map[key] = node
last_category_node = node
self.category_nodes.append(node)
self._create_node_tree(data, state_map)
def _create_node_tree(self, data, state_map):
sort_by = config['sort_tags_by']
eval_formatter = EvalFormatter()
intermediate_nodes = {}
if data is None:
print('_create_node_tree: no data!')
traceback.print_stack()
return
collapse = self.prefs['tags_browser_collapse_at']
collapse_model = self.collapse_model
if collapse == 0:
collapse_model = 'disable'
elif collapse_model != 'disable':
if sort_by == 'name':
collapse_template = tweaks['categories_collapsed_name_template']
elif sort_by == 'rating':
collapse_model = 'partition'
collapse_template = tweaks['categories_collapsed_rating_template']
else:
collapse_model = 'partition'
collapse_template = tweaks['categories_collapsed_popularity_template']
def get_name_components(name):
components = [t.strip() for t in name.split('.') if t.strip()]
if len(components) == 0 or '.'.join(components) != name:
components = [name]
return components
def process_one_node(category, collapse_model, book_rating_map, state_map): # {{{
collapse_letter = None
key = category.category_key
is_gst = category.is_gst
if key not in data:
return
# Use old pref if new one doesn't exist
if key in self.db.prefs.get('tag_browser_dont_collapse',
self.prefs['tag_browser_dont_collapse']):
collapse_model = 'disable'
cat_len = len(data[key])
if cat_len <= 0:
return
category_child_map = {}
fm = self.db.field_metadata[key]
clear_rating = True if key not in self.categories_with_ratings and \
not fm['is_custom'] and \
not fm['kind'] == 'user' \
else False
in_uc = fm['kind'] == 'user' and not is_gst
tt = key if in_uc else None
if collapse_model == 'first letter':
# 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.
fl_collapse_when = self.prefs['tags_browser_collapse_fl_at']
fl_collapse = True if fl_collapse_when > 1 else False
intervals = []
cl_list = [None] * len(data[key])
last_ordnum = 0
last_c = ' '
last_idx = 0
for idx,tag in enumerate(data[key]):
# Deal with items that don't have sorts, such as formats
t = tag.sort if tag.sort else tag.name
c = icu_upper(t) if t else ' '
ordnum, ordlen = collation_order_for_partitioning(c)
if last_ordnum != ordnum:
if fl_collapse and idx > 0:
intervals.append(FL_Interval(last_c, last_c, idx-last_idx))
last_idx = idx
last_c = c[0:ordlen]
last_ordnum = ordnum
cl_list[idx] = last_c
if fl_collapse:
intervals.append(FL_Interval(last_c, last_c, len(cl_list)-last_idx))
# Combine together first letter categories that are smaller
# than the specified option. We choose which item to combine
# by the size of the items before and after, privileging making
# smaller categories. Loop through the intervals doing the combine
# until nothing changes. Multiple iterations are required because
# we might need to combine categories that are already combined.
fl_intervals_changed = True
null_interval = FL_Interval('', '', 100000000)
while fl_intervals_changed and len(intervals) > 1:
fl_intervals_changed = False
for idx,interval in enumerate(intervals):
if interval.length >= fl_collapse_when:
continue
prev = next_ = null_interval
if idx == 0:
next_ = intervals[idx+1]
else:
prev = intervals[idx-1]
if idx < len(intervals) - 1:
next_ = intervals[idx+1]
if prev.length < next_.length:
intervals[idx-1] = FL_Interval(prev.first_chr,
interval.last_chr,
prev.length + interval.length)
else:
intervals[idx+1] = FL_Interval(interval.first_chr,
next_.last_chr,
next_.length + interval.length)
del intervals[idx]
fl_intervals_changed = True
break
# Now correct the first letter list, entering either the letter
# or the range for each item in the category. If we ended up
# with only one 'first letter' category then don't combine
# letters and revert to basic 'by first letter'
if len(intervals) > 1:
cur_idx = 0
for interval in intervals:
first_chr, last_chr, length = interval
for i in range(0, length):
if first_chr == last_chr:
cl_list[cur_idx] = first_chr
else:
cl_list[cur_idx] = f'{first_chr} - {last_chr}'
cur_idx += 1
top_level_component = 'z' + data[key][0].original_name
last_idx = -collapse
category_is_hierarchical = self.is_key_a_hierarchical_category(key)
for idx,tag in enumerate(data[key]):
components = None
if clear_rating:
tag.avg_rating = None
tag.state = state_map.get((tag.name, tag.category), 0)
if collapse_model != 'disable' and cat_len > collapse:
if collapse_model == 'partition':
# Only partition at the top level. This means that we must
# not do a break until the outermost component changes.
if idx >= last_idx + collapse and \
not tag.original_name.startswith(top_level_component+'.'):
if cat_len > idx + collapse:
last = idx + collapse - 1
else:
last = cat_len - 1
if category_is_hierarchical:
ct = copy.copy(data[key][last])
components = get_name_components(ct.original_name)
ct.sort = ct.name = components[0]
d = {'last': ct}
# Do the first node after the last node so that
# the components array contains the right values
# to be used later
ct2 = copy.copy(tag)
components = get_name_components(ct2.original_name)
ct2.sort = ct2.name = components[0]
d['first'] = ct2
else:
d = {'first': tag}
# Some nodes like formats and identifiers don't
# have sort set. Fix that so the template will work
if d['first'].sort is None:
d['first'].sort = tag.name
d['last'] = data[key][last]
if d['last'].sort is None:
d['last'].sort = data[key][last].name
name = eval_formatter.safe_format(collapse_template,
d, '##TAG_VIEW##', None)
if name.startswith('##TAG_VIEW##'):
# Formatter threw an exception. Don't create subnode
node_parent = sub_cat = category
else:
sub_cat = self.create_node(parent=category, data=name,
tooltip=None, temporary=True,
is_category=True,
is_gst=is_gst,
category_key=category.category_key,
icon_map=self.icon_state_map)
sub_cat.tag.is_searchable = False
node_parent = sub_cat
last_idx = idx # remember where we last partitioned
else:
node_parent = sub_cat
else: # by 'first letter'
cl = cl_list[idx]
if cl != collapse_letter:
collapse_letter = cl
sub_cat = self.create_node(parent=category,
data=collapse_letter,
is_category=True,
is_gst=is_gst,
tooltip=None, temporary=True,
category_key=category.category_key,
icon_map=self.icon_state_map)
node_parent = sub_cat
else:
node_parent = category
# category display order is important here. The following works
# only if all the non-User categories are displayed before the
# User categories
if category_is_hierarchical or tag.is_hierarchical:
components = get_name_components(tag.original_name)
else:
components = [tag.original_name]
if (not tag.is_hierarchical) and (in_uc or
(fm['is_custom'] and fm['display'].get('is_names', False)) or
not category_is_hierarchical or len(components) == 1):
n = self.create_node(parent=node_parent, data=tag, tooltip=tt,
is_gst=is_gst, icon_map=self.icon_state_map)
category_child_map[tag.name, tag.category] = n
else:
child_key = key if is_gst else tag.category
for i,comp in enumerate(components):
if i == 0:
child_map = category_child_map
top_level_component = comp
else:
child_map = {(t.tag.name, key if is_gst else t.tag.category):
t for t in node_parent.children
if t.type != TagTreeItem.CATEGORY}
if (comp,child_key) in child_map:
node_parent = child_map[(comp,child_key)]
t = node_parent.tag
t.is_hierarchical = '5state' if tag.category != 'search' else '3state'
if tag.id_set is not None and t.id_set is not None:
t.id_set = t.id_set | tag.id_set
intermediate_nodes[t.original_name,child_key] = t
else:
if i < len(components)-1:
original_name = '.'.join(components[:i+1])
t = intermediate_nodes.get((original_name, child_key), None)
if t is None:
t = copy.copy(tag)
t.original_name = original_name
t.count = 0
if key != 'search':
# This 'manufactured' intermediate node can
# be searched, but cannot be edited.
t.is_editable = False
else:
t.is_searchable = t.is_editable = False
t.search_expression = None
intermediate_nodes[original_name,child_key] = t
else:
t = tag
if not in_uc:
t.original_name = t.name
intermediate_nodes[t.original_name,child_key] = t
t.is_hierarchical = \
'5state' if t.category != 'search' else '3state'
t.name = comp
node_parent = self.create_node(parent=node_parent,
data=t, is_gst=is_gst, tooltip=tt,
icon_map=self.icon_state_map)
child_map[(comp, child_key)] = node_parent
# Correct the average rating for the node
total = count = 0
for book_id in t.id_set:
rating = book_rating_map.get(book_id, 0)
if rating:
total += rating/2.0
count += 1
node_parent.cached_average_rating = float(total)/count if total and count else 0
return
# }}}
# Build the entire node tree. Note that category_nodes is in field
# metadata order so the User categories will be at the end
with self.db.new_api.safe_read_lock: # needed as we read from book_value_map
for category in self.category_nodes:
process_one_node(category, collapse_model, self.db.new_api.fields['rating'].book_value_map,
state_map.get(category.category_key, {}))
# Fix up the node tree, reordering as needed and deleting undisplayed
# nodes. First, remove empty user category subnodes if needed. This is a
# recursive process because the hierarchical categories were combined
# together in process_one_node (above), which also computes the child
# count.
if self.prefs['tag_browser_hide_empty_categories']:
def process_uc_children(parent, depth):
new_children = []
for node in parent.children:
if node.type == TagTreeItem.CATEGORY:
# I could De Morgan's this but I think it is more
# understandable this way
if node.category_key.startswith('@') and len(node.children) == 0:
pass
else:
new_children.append(node)
process_uc_children(node, depth+1)
else:
new_children.append(node)
parent.children = new_children
for node in self.root_item.children:
if node.category_key.startswith('@'):
process_uc_children(node, 1)
# Now check the standard categories and root-level user categories,
# removing any hidden categories and if needed, empty categories
new_children = []
for node in self.root_item.children:
if self.prefs['tag_browser_hide_empty_categories'] and len(node.child_tags()) == 0:
continue
key = node.category_key
if key in self.row_map:
if self.hidden_categories:
if key in self.hidden_categories:
continue
found = False
for cat in self.hidden_categories:
if cat.startswith('@') and key.startswith(cat + '.'):
found = True
if found:
continue
new_children.append(node)
self.root_item.children = new_children
self.root_item.children.sort(key=lambda x: self.row_map.index(x.category_key))
if self.set_in_tag_browser():
self.research_required.emit()
def set_in_tag_browser(self):
# If the filter isn't set then don't build the list, improving
# performance significantly for large libraries or libraries with lots
# of categories. This means that in_tag_browser:true with no filter will
# return all books. This is incorrect in the rare case where the
# category list in the tag browser doesn't contain a category like
# authors that by definition matches all books because all books have an
# author. If really needed the user can work around this 'error' by
# clicking on the categories of interest with the connector set to 'or'.
if self.filter_categories_by:
id_set = set()
for x in (a for a in self.root_item.children if a.category_key != 'search' and not a.is_gst):
for t in x.child_tags():
id_set |= t.tag.id_set
else:
id_set = None
changed = self.db.data.get_in_tag_browser() != id_set
self.db.data.set_in_tag_browser(id_set)
return changed
def get_category_editor_data(self, category):
for cat in self.root_item.children:
if cat.category_key == category:
return [(t.tag.id, t.tag.original_name, t.tag.count)
for t in cat.child_tags() if t.tag.count > 0]
def is_in_user_category(self, index):
if not index.isValid():
return False
p = self.get_node(index)
while p.type != TagTreeItem.CATEGORY:
p = p.parent
return p.tag.category.startswith('@')
def is_key_a_hierarchical_category(self, key):
result = self.hierarchical_categories.get(key)
if result is None:
result = not (
key in ['authors', 'publisher', 'news', 'formats', 'rating'] or
key not in self.db.new_api.pref('categories_using_hierarchy', []) or
config['sort_tags_by'] != 'name')
self.hierarchical_categories[key] = result
return result
def is_index_on_a_hierarchical_category(self, index):
if not index.isValid():
return False
p = self.get_node(index)
return self.is_key_a_hierarchical_category(p.tag.category)
# Drag'n Drop {{{
def mimeTypes(self):
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
node = self.get_node(idx)
path = self.path_for_index(idx)
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, p.is_gst, t.original_name,
t.category, path)
data.append(d)
else:
data.append(None)
raw = bytearray(json_dumps(data))
ans = QMimeData()
ans.setData('application/calibre+from_tag_browser', raw)
return ans
def dropMimeData(self, md, action, row, column, parent):
fmts = {str(x) for x in md.formats()}
if not fmts.intersection(set(self.mimeTypes())):
return False
if "application/calibre+from_library" in fmts:
if action != Qt.DropAction.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)
def do_drop_from_tag_browser(self, md, action, row, column, parent):
if not parent.isValid():
return False
dest = self.get_node(parent)
if not md.hasFormat('application/calibre+from_tag_browser'):
return False
data = bytes(md.data('application/calibre+from_tag_browser'))
src = json_loads(data)
if len(src) == 1:
# Check to see if this is a hierarchical rename
s = src[0]
# This check works for both hierarchical and user categories.
# We can drag only tag items.
if s[0] != TagTreeItem.TAG:
return False
src_index = self.index_for_path(s[5])
if src_index == parent:
# dropped on itself
return False
src_item = self.get_node(src_index)
dest_item = parent.data(Qt.ItemDataRole.UserRole)
# Here we do the real work. If src is a tag, src == dest, and src
# is hierarchical then we can do a rename.
if (src_item.type == TagTreeItem.TAG and
src_item.tag.category == dest_item.tag.category and
self.is_key_a_hierarchical_category(src_item.tag.category)):
key = s[1]
# work out the part of the source name to use in the rename
# It isn't necessarily a simple name but might be the remaining
# levels of the hierarchy
part = src_item.tag.original_name.rpartition('.')
src_simple_name = part[2]
# work out the new prefix, the destination node name
if dest.type == TagTreeItem.TAG:
new_name = dest_item.tag.original_name + '.' + src_simple_name
else:
new_name = src_simple_name
if self.get_in_vl():
src_item.use_vl = rename_only_in_vl_question(self.gui_parent)
else:
src_item.use_vl = False
self.rename_item(src_item, key, new_name)
return True
# Should be working with a user category
if dest.type != TagTreeItem.CATEGORY:
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, category key is global search term,
full name, category key, path to node)
The type must be TagTreeItem.TAG
dest is the TagTreeItem node to receive the items
action is Qt.DropAction.CopyAction or Qt.DropAction.MoveAction
'''
def process_source_node(user_cats, src_parent, src_parent_is_gst,
is_uc, dest_key, idx):
'''
Copy/move an item and all its children to the destination
'''
copied = False
src_name = idx.tag.original_name
src_cat = idx.tag.category
# delete the item if the source is a User category and action is move
if is_uc and not src_parent_is_gst and src_parent in user_cats and \
action == Qt.DropAction.MoveAction:
new_cat = []
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
else:
copied = True
# Now add the item to the destination User category
add_it = True
if not is_uc and src_cat == 'news':
src_cat = 'tags'
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])
for c in idx.children:
copied = process_source_node(user_cats, src_parent, src_parent_is_gst,
is_uc, dest_key, c)
return copied
user_cats = self.db.new_api.pref('user_categories', {})
path = None
for s in src:
src_parent, src_parent_is_gst = s[1:3]
path = s[5]
if src_parent.startswith('@'):
is_uc = True
src_parent = src_parent[1:]
else:
is_uc = False
dest_key = dest.category_key[1:]
if dest_key not in user_cats:
continue
idx = self.index_for_path(path)
if idx.isValid():
process_source_node(user_cats, src_parent, src_parent_is_gst,
is_uc, dest_key,
self.get_node(idx))
self.db.new_api.set_pref('user_categories', user_cats)
self.refresh_required.emit()
self.user_category_added.emit()
return True
def do_drop_from_library(self, md, action, row, column, parent):
idx = parent
if idx.isValid():
node = self.data(idx, Qt.ItemDataRole.UserRole)
if node.type == TagTreeItem.TAG:
fm = self.db.metadata_for_field(node.tag.category)
if node.tag.category in \
('tags', 'series', 'authors', 'rating', 'publisher', 'languages', 'formats') or \
(fm['is_custom'] and (
fm['datatype'] in ['text', 'rating', 'series',
'enumeration'] or (
fm['datatype'] == 'composite' and
fm['display'].get('make_category', False)))):
mime = 'application/calibre+from_library'
ids = list(map(int, md.data(mime).data().split()))
self.handle_drop(node, ids)
return True
elif node.type == TagTreeItem.CATEGORY:
fm_dest = self.db.metadata_for_field(node.category_key)
if fm_dest['kind'] == 'user':
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', 'enumeration'] and
not fm_src['is_multiple'])or
(fm_src['datatype'] == 'composite' and
fm_src['display'].get('make_category', False))):
mime = 'application/calibre+from_library'
ids = list(map(int, md.data(mime).data().split()))
self.handle_user_category_drop(node, ids, md.column_name)
return True
return False
def handle_user_category_drop(self, on_node, ids, column):
categories = self.db.new_api.pref('user_categories', {})
cat_contents = categories.get(on_node.category_key[1:], None)
if cat_contents is None:
return
cat_contents = {(v, c) for v,c,ign in cat_contents}
fm_src = self.db.metadata_for_field(column)
label = fm_src['label']
for id in ids:
if not fm_src['is_custom']:
if label == 'authors':
value = self.db.authors(id, index_is_id=True)
value = [v.replace('|', ',') for v in value.split(',')]
elif label == 'publisher':
value = self.db.publisher(id, index_is_id=True)
elif label == 'series':
value = self.db.series(id, index_is_id=True)
else:
if fm_src['datatype'] != 'composite':
value = self.db.get_custom(id, label=label, index_is_id=True)
else:
value = self.db.get_property(id, loc=fm_src['rec_index'],
index_is_id=True)
if value:
if not isinstance(value, list):
value = [value]
cat_contents |= {(v, column) for v in value}
categories[on_node.category_key[1:]] = [[v, c, 0] for v,c in cat_contents]
self.db.new_api.set_pref('user_categories', categories)
self.refresh_required.emit()
self.user_category_added.emit()
def handle_drop_on_format(self, fmt, book_ids):
self.convert_requested.emit(book_ids, fmt)
def handle_drop(self, on_node, ids):
# print 'Dropped ids:', ids, on_node.tag
key = on_node.tag.category
if key == 'formats':
self.handle_drop_on_format(on_node.tag.name, ids)
return
if (key == 'authors' and len(ids) >= 5):
if not confirm('<p>'+_('Changing the authors for several books can '
'take a while. Are you sure?') +
'</p>', 'tag_browser_drop_authors', self.gui_parent):
return
elif len(ids) > 15:
if not confirm('<p>'+_('Changing the metadata for that many books '
'can take a while. Are you sure?') +
'</p>', 'tag_browser_many_changes', self.gui_parent):
return
fm = self.db.metadata_for_field(key)
is_multiple = fm['is_multiple']
val = on_node.tag.original_name
for id in ids:
mi = self.db.get_metadata(id, index_is_id=True)
# Prepare to ignore the author, unless it is changed. Title is
# always ignored -- see the call to set_metadata
set_authors = False
# Author_sort cannot change explicitly. Changing the author might
# change it.
mi.author_sort = None # Never will change by itself.
if key == 'authors':
mi.authors = [val]
set_authors=True
elif fm['datatype'] == 'rating':
mi.set(key, len(val) * 2)
elif fm['datatype'] == 'series':
series_index = self.db.new_api.get_next_series_num_for(val, field=key)
if fm['is_custom']:
mi.set(key, val, extra=series_index)
else:
mi.series, mi.series_index = val, series_index
elif is_multiple:
new_val = mi.get(key, [])
if val in new_val:
# Fortunately, only one field can change, so the continue
# won't break anything
continue
new_val.append(val)
mi.set(key, new_val)
else:
mi.set(key, val)
self.db.set_metadata(id, mi, set_title=False,
set_authors=set_authors, commit=False)
self.db.commit()
self.drag_drop_finished.emit(ids)
# }}}
def get_in_vl(self):
return self.db.data.get_base_restriction() or self.db.data.get_search_restriction()
def get_book_ids_to_use(self):
if self.db.data.get_base_restriction() or self.db.data.get_search_restriction():
return self.db.search('', return_matches=True, sort_results=False)
return None
def get_ordered_categories(self, use_defaults=False, pref_data_override=None):
if use_defaults:
tbo = []
elif pref_data_override:
tbo = [k for k,_ in pref_data_override]
else:
tbo = self.db.new_api.pref('tag_browser_category_order', [])
return category_display_order(tbo, list(self.categories.keys()))
def _get_category_nodes(self, sort):
'''
Called by __init__. Do not directly call this method.
'''
self.row_map = []
self.categories = OrderedDict()
# Get the categories
try:
# We must disable the in_tag_browser ids because we want all the
# categories that will be filtered later. They might be restricted
# by a VL or extra restriction.
old_in_tb = self.db.data.get_in_tag_browser()
self.db.data.set_in_tag_browser(None)
data = self.db.new_api.get_categories(sort=sort,
book_ids=self.get_book_ids_to_use(),
first_letter_sort=self.collapse_model == 'first letter')
self.db.data.set_in_tag_browser(old_in_tb)
except Exception as e:
traceback.print_exc()
data = self.db.new_api.get_categories(sort=sort,
first_letter_sort=self.collapse_model == 'first letter')
self.restriction_error.emit(str(e))
if self.filter_categories_by:
if self.filter_categories_by.startswith('='):
use_exact_match = True
filter_by = self.filter_categories_by[1:]
else:
use_exact_match = False
filter_by = self.filter_categories_by
if prefs['use_primary_find_in_search']:
def final_equals(x, y):
return primary_strcmp(x, y) == 0
def final_contains(x, y):
return primary_contains(x, y)
else:
def final_equals(x, y):
return strcmp(x, y) == 0
def final_contains(filt, txt):
return contains(filt, icu_lower(txt))
for category in data.keys():
if use_exact_match:
data[category] = [t for t in data[category]
if final_equals(t.name, filter_by)]
else:
data[category] = [t for t in data[category]
if final_contains(filter_by, t.name)]
# Build a dict of the keys that have data.
# Always add user categories so that the constructed hierarchy works.
# This means that empty categories will be displayed unless the 'hide
# empty categories' box is checked.
tb_categories = self.db.field_metadata
for category in tb_categories:
if category in data or category.startswith('@'):
self.categories[category] = tb_categories[category]['name']
# Now build the list of fields in display order. A lot of this is to
# maintain compatibility with the tweaks.
order_pref = self.db.new_api.pref('tag_browser_category_order', None)
if order_pref is not None:
# Keys are in order
self.row_map = self.get_ordered_categories()
else:
order = tweaks.get('tag_browser_category_default_sort', 'default')
self.row_map = list(self.categories.keys())
if order not in ('default', 'display_name', 'lookup_name'):
print('Tweak tag_browser_category_default_sort is not valid. Ignored')
order = 'default'
if order != 'default':
def key_func(val):
if order == 'display_name':
return icu_lower(self.db.field_metadata[val]['name'])
return icu_lower(val[1:] if val.startswith('#') or val.startswith('@') else val)
direction = tweaks.get('tag_browser_category_default_sort_direction', 'ascending')
if direction not in ('ascending', 'descending'):
print('Tweak tag_browser_category_default_sort_direction is not valid. Ignored')
direction = 'ascending'
self.row_map.sort(key=key_func, reverse=direction == 'descending')
try:
order = tweaks.get('tag_browser_category_order', {'*':1})
if not isinstance(order, dict):
raise TypeError()
except:
print('Tweak tag_browser_category_order is not valid. Ignored')
order = {'*': 1000}
defvalue = order.get('*', 1000)
self.row_map.sort(key=lambda x: order.get(x, defvalue))
# Migrate the tweak to the new pref. First, make sure the order is valid
self.row_map = self.get_ordered_categories(pref_data_override=[[k,None] for k in self.row_map])
self.db.new_api.set_pref('tag_browser_category_order', self.row_map)
return data
def set_categories_filter(self, txt):
if txt:
self.filter_categories_by = icu_lower(txt)
else:
self.filter_categories_by = None
def get_categories_filter(self):
return self.filter_categories_by
def refresh(self, data=None):
'''
Here to trap usages of refresh in the old architecture. Can eventually
be removed.
'''
print('TagsModel: refresh called!')
traceback.print_stack()
return False
def create_node(self, *args, **kwargs):
node = TagTreeItem(*args, **kwargs)
self.node_map[id(node)] = node
node.category_custom_icons = self.category_custom_icons
return node
def get_node(self, idx):
ans = self.node_map.get(idx.internalId(), self.root_item)
return ans
def createIndex(self, row, column, internal_pointer=None):
idx = QAbstractItemModel.createIndex(self, row, column,
id(internal_pointer))
return idx
def category_row_map(self):
return {category.category_key:row for row, category in enumerate(self.root_item.children)}
def index_for_category(self, name):
for row, category in enumerate(self.root_item.children):
if category.category_key == name:
return self.index(row, 0, QModelIndex())
def columnCount(self, parent):
return 1
def data(self, index, role):
if not index.isValid():
return None
item = self.get_node(index)
return item.data(role)
def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
if not index.isValid():
return False
# set up to reposition at the same item. We can do this except if
# working with the last item and that item is deleted, in which case
# we position at the parent label
val = str(value or '').strip()
if not val:
return self.show_error_after_event_loop_tick(_('Item is blank'),
_('An item cannot be set to nothing. Delete it instead.'))
item = self.get_node(index)
if item.type == TagTreeItem.CATEGORY and item.category_key.startswith('@'):
if val.find('.') >= 0:
return self.show_error_after_event_loop_tick(_('Rename User category'),
_('You cannot use periods in the name when '
'renaming User categories'))
user_cats = self.db.new_api.pref('user_categories', {})
user_cat_keys_lower = [icu_lower(k) for k in user_cats]
ckey = item.category_key[1:]
ckey_lower = icu_lower(ckey)
dotpos = ckey.rfind('.')
if dotpos < 0:
nkey = val
else:
nkey = ckey[:dotpos+1] + val
nkey_lower = icu_lower(nkey)
if ckey == nkey:
self.use_position_based_index_on_next_recount = True
return True
for c in sorted(list(user_cats.keys()), key=sort_key):
if icu_lower(c).startswith(ckey_lower):
if len(c) == len(ckey):
if strcmp(ckey, nkey) != 0 and \
nkey_lower in user_cat_keys_lower:
return self.show_error_after_event_loop_tick(_('Rename User category'),
_('The name %s is already used')%nkey)
user_cats[nkey] = user_cats[ckey]
self.rename_user_category_icon('@' + c, '@' + nkey)
del user_cats[ckey]
elif c[len(ckey)] == '.':
rest = c[len(ckey):]
if strcmp(ckey, nkey) != 0 and \
icu_lower(nkey + rest) in user_cat_keys_lower:
return self.show_error_after_event_loop_tick(_('Rename User category'),
_('The name %s is already used')%(nkey + rest))
user_cats[nkey + rest] = user_cats[ckey + rest]
self.rename_user_category_icon('@' + ckey + rest, '@' + nkey + rest)
del user_cats[ckey + rest]
self.user_categories_edited.emit(user_cats, nkey) # Does a refresh
self.use_position_based_index_on_next_recount = True
return True
key = item.tag.category
# make certain we know about the item's category
if key not in self.db.field_metadata:
return False
if key == 'authors':
if val.find('&') >= 0:
return self.show_error_after_event_loop_tick(_('Invalid author name'),
_('Author names cannot contain & characters.'))
return False
if key == 'search':
if val == str(item.data(role) or ''):
return True
if val in self.db.saved_search_names():
return self.show_error_after_event_loop_tick(
_('Duplicate search name'), _('The saved search name %s is already used.')%val)
self.use_position_based_index_on_next_recount = True
self.db.saved_search_rename(str(item.data(role) or ''), val)
item.tag.name = val
self.search_item_renamed.emit() # Does a refresh
else:
self.rename_item(item, key, val)
return True
def show_error_after_event_loop_tick(self, title, msg, det_msg=''):
self.show_error_after_event_loop_tick_signal.emit(title, msg, det_msg)
return False
def on_show_error_after_event_loop_tick(self, title, msg, details):
error_dialog(self.gui_parent, title, msg, det_msg=details, show=True)
def rename_item(self, item, key, to_what):
def do_one_item(lookup_key, an_item, original_name, new_name, restrict_to_books):
self.use_position_based_index_on_next_recount = True
self.db.new_api.rename_items(lookup_key, {an_item.tag.id: new_name},
restrict_to_book_ids=restrict_to_books)
self.tag_item_renamed.emit()
an_item.tag.name = new_name
an_item.tag.state = TAG_SEARCH_STATES['clear']
self.use_position_based_index_on_next_recount = True
self.add_renamed_item_to_user_categories(lookup_key, original_name, new_name)
children = item.all_children()
restrict_to_book_ids=self.get_book_ids_to_use() if item.use_vl else None
if item.tag.is_editable and len(children) == 0:
# Leaf node, just do it.
do_one_item(key, item, item.tag.original_name, to_what, restrict_to_book_ids)
else:
# Middle node of a hierarchy
search_name = item.tag.original_name
# Clear any search icons on the original tag
if item.parent.type == TagTreeItem.TAG:
item.parent.tag.state = TAG_SEARCH_STATES['clear']
# It might also be a leaf
if item.tag.is_editable:
do_one_item(key, item, item.tag.original_name, to_what, restrict_to_book_ids)
# Now do the children
for child_item in children:
from calibre.utils.icu import startswith
if (child_item.tag.is_editable and
startswith(child_item.tag.original_name, search_name)):
new_name = to_what + child_item.tag.original_name[len(search_name):]
do_one_item(key, child_item, child_item.tag.original_name,
new_name, restrict_to_book_ids)
self.clean_items_from_user_categories()
self.refresh_required.emit()
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.
'''
user_cats = self.db.new_api.pref('user_categories', {})
for k in user_cats.keys():
ucat = {n:c for n,c,_ in user_cats[k]}
# Check if the new name with the same category already exists. If
# so, remove the old name because it would be a duplicate. This can
# happen if two items in the item_category were renamed to the same
# name.
if ucat.get(new_name, None) == item_category:
if ucat.pop(item_name, None) is not None:
# Only update the user_cats when something changes
user_cats[k] = list([(n, c, 0) for n, c in ucat.items()])
elif ucat.get(item_name, None) == item_category:
# If the old name/item_category exists, rename it to the new
# name using del/add
del ucat[item_name]
ucat[new_name] = item_category
user_cats[k] = list([(n, c, 0) for n, c in ucat.items()])
self.db.new_api.set_pref('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.
'''
user_cats = self.db.new_api.pref('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.new_api.set_pref('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.new_api.pref('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.new_api.set_pref('user_categories', user_cats)
def add_renamed_item_to_user_categories(self, lookup_key, original_name, new_name):
'''
Add new_name to any user category that contains original name if new_name
isn't already there. The original name isn't deleted. This is the first
step when renaming user categories that might be in virtual libraries
because when finished both names may still exist. You should call
clean_items_from_user_categories() when done to remove any keys that no
longer exist from all user categories. The caller must arrange to
redisplay the tree as appropriate.
'''
user_cats = self.db.new_api.pref('user_categories', {})
for cat in user_cats.keys():
found_original = False
found_new = False
for name,key,_ in user_cats[cat]:
if key == lookup_key:
if name == original_name:
found_original = True
if name == new_name:
found_new = True
if found_original and not found_new:
user_cats[cat].append([new_name, lookup_key, 0])
self.db.new_api.set_pref('user_categories', user_cats)
def clean_items_from_user_categories(self):
'''
Remove any items that no longer exist from user categories. This can
happen when renaming items in virtual libraries, where sometimes the
old name still exists on some book not in the VL and sometimes it
doesn't. The caller must arrange to redisplay the tree as appropriate.
'''
user_cats = self.db.new_api.pref('user_categories', {})
cache = self.db.new_api
all_cats = {}
for cat in user_cats.keys():
new_cat = []
for val, key, _ in user_cats[cat]:
datatype = cache.field_metadata.get(key, {}).get('datatype', '*****')
if datatype != 'composite':
id_ = cache.get_item_id(key, val)
if id_ is not None:
v = cache.books_for_field(key, id_)
if v:
new_cat.append([val, key, 0])
if new_cat:
all_cats[cat] = new_cat
self.db.new_api.set_pref('user_categories', all_cats)
def headerData(self, *args):
return None
def flags(self, index, *args):
ans = Qt.ItemFlag.ItemIsEnabled|Qt.ItemFlag.ItemIsEditable
if index.isValid():
node = self.data(index, Qt.ItemDataRole.UserRole)
if node.type == TagTreeItem.TAG:
tag = node.tag
category = tag.category
if (tag.is_editable or tag.is_hierarchical) and category != 'search':
ans |= Qt.ItemFlag.ItemIsDragEnabled
fm = self.db.metadata_for_field(category)
if category in \
('tags', 'series', 'authors', 'rating', 'publisher', 'languages', 'formats') or \
(fm['is_custom'] and
fm['datatype'] in ['text', 'rating', 'series', 'enumeration']):
ans |= Qt.ItemFlag.ItemIsDropEnabled
else:
if node.type != TagTreeItem.CATEGORY or node.category_key != 'formats':
ans |= Qt.ItemFlag.ItemIsDropEnabled
return ans
def supportedDropActions(self):
return Qt.DropAction.CopyAction|Qt.DropAction.MoveAction
def named_path_for_index(self, index):
ans = []
while index.isValid():
node = self.get_node(index)
if node is self.root_item:
break
ans.append(node.name_id)
index = self.parent(index)
return ans
def index_for_named_path(self, named_path):
parent = self.root_item
ipath = []
path = named_path[:]
while path:
q = path.pop()
for i, c in enumerate(parent.children):
if c.name_id == q:
ipath.append(i)
parent = c
break
else:
break
return self.index_for_path(ipath)
def path_for_index(self, index):
ans = []
while index.isValid():
ans.append(index.row())
index = self.parent(index)
ans.reverse()
return ans
def index_for_path(self, path):
parent = QModelIndex()
for idx,v in enumerate(path):
tparent = self.index(v, 0, parent)
if not tparent.isValid():
if v > 0 and idx == len(path) - 1:
# Probably the last item went away. Use the one before it
tparent = self.index(v-1, 0, parent)
if not tparent.isValid():
# Not valid. Use the last valid index
break
else:
# There isn't one before it. Use the last valid index
break
parent = tparent
return parent
def index(self, row, column, parent):
if not self.hasIndex(row, column, parent):
return QModelIndex()
if not parent.isValid():
parent_item = self.root_item
else:
parent_item = self.get_node(parent)
try:
child_item = parent_item.children[row]
except IndexError:
return QModelIndex()
ans = self.createIndex(row, column, child_item)
return ans
def parent(self, index):
if not index.isValid():
return QModelIndex()
child_item = self.get_node(index)
parent_item = getattr(child_item, 'parent', None)
if parent_item is self.root_item or parent_item is None:
return QModelIndex()
ans = self.createIndex(parent_item.row(), 0, parent_item)
if not ans.isValid():
return QModelIndex()
return ans
def rowCount(self, parent):
if parent.column() > 0:
return 0
if not parent.isValid():
parent_item = self.root_item
else:
parent_item = self.get_node(parent)
return len(parent_item.children)
def reset_all_states(self, except_=None):
update_list = []
def process_tag(tag_item):
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)
for t in self.root_item.children:
process_tag(t)
def clear_state(self):
self.reset_all_states()
def toggle(self, index, exclusive, set_to=None):
'''
exclusive: clear all states before applying this one
set_to: None => advance the state, otherwise a value from TAG_SEARCH_STATES
'''
if not index.isValid():
return False
item = self.get_node(index)
tag = item.tag
if tag.category == 'search' and tag.search_expression is None:
return False
item.toggle(set_to=set_to)
if exclusive:
self.reset_all_states(except_=item.tag)
self.dataChanged.emit(index, index)
return True
def tokens(self):
ans = []
# Tags can be in the news and the tags categories. However, because of
# the desire to use two different icons (tags and news), the nodes are
# not shared, which can lead to the possibility of searching twice for
# the same tag. The tags_seen set helps us prevent that
tags_seen = set()
# Tag nodes are in their own category and possibly in User categories.
# 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()
stars = rating_to_stars(3, True)
node_searches = {TAG_SEARCH_STATES['mark_plus'] : 'true',
TAG_SEARCH_STATES['mark_plusplus'] : '.true',
TAG_SEARCH_STATES['mark_minus'] : 'false',
TAG_SEARCH_STATES['mark_minusminus'] : '.false'}
for node in self.category_nodes:
if node.tag.state:
if node.category_key == "news":
if node_searches[node.tag.state] == 'true':
ans.append('tags:"=' + _('News') + '"')
else:
ans.append('( not tags:"=' + _('News') + '")')
else:
ans.append('%s:%s'%(node.category_key, node_searches[node.tag.state]))
key = node.category_key
for tag_item in node.all_children():
if tag_item.type == TagTreeItem.CATEGORY:
if self.collapse_model == 'first letter' and \
tag_item.temporary and not key.startswith('@') \
and tag_item.tag.state:
k = 'author_sort' if key == 'authors' else key
letters_seen = {}
for subnode in tag_item.children:
if subnode.tag.sort:
c = subnode.tag.sort[0]
if c in r'\.^$[]|()':
c = f'\\{c}'
letters_seen[c] = True
if letters_seen:
charclass = ''.join(letters_seen)
if k == 'author_sort':
expr = r'%s:"""~(^[%s])|(&\s*[%s])"""'%(k, charclass, charclass)
elif k == 'series':
expr = r'series_sort:"""~^[%s]"""'%(charclass)
else:
expr = r'%s:"""~^[%s]"""'%(k, charclass)
else:
expr = r'%s:false'%(k)
if node_searches[tag_item.tag.state] == 'true':
ans.append(expr)
else:
ans.append('(not ' + expr + ')')
continue
tag = tag_item.tag
if tag.state != TAG_SEARCH_STATES['clear']:
if tag.state == TAG_SEARCH_STATES['mark_minus'] or \
tag.state == TAG_SEARCH_STATES['mark_minusminus']:
prefix = ' not '
else:
prefix = ''
if node.is_gst:
category = key
else:
category = tag.category if key != 'news' else 'tag'
add_colon = False
if self.db.field_metadata[tag.category]['is_csp']:
add_colon = True
if tag.name and tag.name[0] in stars: # char is a star or a half. Assume rating
rnum = len(tag.name)
if tag.name.endswith(stars[-1]):
rnum = '%s.5' % (rnum - 1)
ans.append('%s%s:%s'%(prefix, category, rnum))
else:
name = tag.original_name
use_prefix = tag.state in [TAG_SEARCH_STATES['mark_plusplus'],
TAG_SEARCH_STATES['mark_minusminus']]
if category == 'tags':
if name in tags_seen:
continue
tags_seen.add(name)
if tag in nodes_seen:
continue
nodes_seen.add(tag)
n = name.replace(r'"', r'\"')
if name.startswith('.'):
n = '.' + n
ans.append('%s%s:"=%s%s%s"'%(prefix, category,
'.' if use_prefix else '', n,
':' if add_colon else ''))
return ans
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 (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) if not equals_match else txt
self.path_found = None
if start_path is None:
start_path = []
if prefs['use_primary_find_in_search']:
def final_equals(x, y):
return primary_strcmp(x, y) == 0
def final_contains(x, y):
return primary_contains(x, y)
else:
def final_equals(x, y):
return strcmp(x, y) == 0
def final_contains(filt, txt):
return contains(filt, icu_lower(txt))
def process_tag(depth, tag_index, tag_item, start_path):
path = self.path_for_index(tag_index)
if depth < len(start_path) and path[depth] <= start_path[depth]:
return False
tag = tag_item.tag
if tag is None:
return False
name = tag.original_name
if ((equals_match and final_equals(name, txt)) or
(not equals_match and final_contains(txt, name))):
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):
path = self.path_for_index(category_index)
if depth < len(start_path):
if path[depth] < start_path[depth]:
return False
if path[depth] > start_path[depth]:
start_path = path
my_key = self.get_node(category_index).category_key
for j in range(self.rowCount(category_index)):
tag_index = self.index(j, 0, category_index)
tag_item = self.get_node(tag_index)
if tag_item.type == TagTreeItem.CATEGORY:
if process_level(depth+1, tag_index, start_path):
return True
elif not key or strcmp(key, my_key) == 0:
if process_tag(depth+1, tag_index, tag_item, start_path):
return True
return False
for i in range(self.rowCount(QModelIndex())):
if process_level(0, self.index(i, 0, QModelIndex()), start_path):
break
return self.path_found
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
the node. Paths are as in find_item_node.
'''
if not key:
return None
for i in range(self.rowCount(parent)):
idx = self.index(i, 0, parent)
node = self.get_node(idx)
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 set_boxed(self, idx):
tag_item = self.get_node(idx)
tag_item.boxed = True
self.dataChanged.emit(idx, idx)
def clear_boxed(self):
'''
Clear all boxes around items.
'''
def process_tag(tag_index, tag_item):
if tag_item.boxed:
tag_item.boxed = False
self.dataChanged.emit(tag_index, tag_index)
for i,c in enumerate(tag_item.children):
process_tag(self.index(i, 0, tag_index), c)
def process_level(category_index):
for j in range(self.rowCount(category_index)):
tag_index = self.index(j, 0, category_index)
tag_item = self.get_node(tag_index)
if tag_item.boxed:
tag_item.boxed = False
self.dataChanged.emit(tag_index, tag_index)
if tag_item.type == TagTreeItem.CATEGORY:
process_level(tag_index)
else:
process_tag(tag_index, tag_item)
for i in range(self.rowCount(QModelIndex())):
process_level(self.index(i, 0, QModelIndex()))
# }}}