1#!/usr/local/bin/python3.8
2# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
3
4
5__license__   = 'GPL v3'
6__copyright__ = '2011, Kovid Goyal <kovid@kovidgoyal.net>'
7__docformat__ = 'restructuredtext en'
8
9import copy
10import os
11import traceback
12from collections import OrderedDict, namedtuple
13from qt.core import (
14    QAbstractItemModel, QFont, QIcon, QMimeData, QModelIndex, QObject, Qt,
15    pyqtSignal
16)
17
18from calibre.constants import config_dir
19from calibre.db.categories import Tag
20from calibre.ebooks.metadata import rating_to_stars
21from calibre.gui2 import config, error_dialog, file_icon_provider, gprefs, question_dialog
22from calibre.gui2.dialogs.confirm_delete import confirm
23from calibre.library.field_metadata import category_icon_map
24from calibre.utils.config import prefs, tweaks
25from calibre.utils.formatter import EvalFormatter
26from calibre.utils.icu import (
27    contains, lower, primary_contains, primary_strcmp, sort_key,
28    strcmp, collation_order_for_partitioning
29)
30from calibre.utils.serialize import json_dumps, json_loads
31from polyglot.builtins import iteritems, itervalues
32
33TAG_SEARCH_STATES = {'clear': 0, 'mark_plus': 1, 'mark_plusplus': 2,
34                     'mark_minus': 3, 'mark_minusminus': 4}
35DRAG_IMAGE_ROLE = Qt.ItemDataRole.UserRole + 1000
36COUNT_ROLE = DRAG_IMAGE_ROLE + 1
37
38_bf = None
39
40
41def bf():
42    global _bf
43    if _bf is None:
44        _bf = QFont()
45        _bf.setBold(True)
46        _bf = (_bf)
47    return _bf
48
49
50class TagTreeItem:  # {{{
51
52    CATEGORY = 0
53    TAG      = 1
54    ROOT     = 2
55    category_custom_icons = {}
56    file_icon_provider = None
57
58    def __init__(self, data=None, is_category=False, icon_map=None,
59                 parent=None, tooltip=None, category_key=None, temporary=False):
60        if self.file_icon_provider is None:
61            self.file_icon_provider = TagTreeItem.file_icon_provider = file_icon_provider().icon_from_ext
62        self.parent = parent
63        self.children = []
64        self.blank = QIcon()
65        self.is_gst = False
66        self.boxed = False
67        self.temporary = False
68        self.can_be_edited = False
69        self.icon_state_map = list(icon_map)
70        if self.parent is not None:
71            self.parent.append(self)
72
73        if data is None:
74            self.type = self.ROOT
75        else:
76            self.type = self.CATEGORY if is_category else self.TAG
77
78        if self.type == self.CATEGORY:
79            self.name = data
80            self.py_name = data
81            self.category_key = category_key
82            self.temporary = temporary
83            self.tag = Tag(data, category=category_key,
84                   is_editable=category_key not in
85                            ['news', 'search', 'identifiers', 'languages'],
86                   is_searchable=category_key not in ['search'])
87        elif self.type == self.TAG:
88            self.tag = data
89            self.cached_average_rating = None
90            self.cached_item_count = None
91
92        self.tooltip = tooltip or ''
93
94    @property
95    def name_id(self):
96        if self.type == self.CATEGORY:
97            return self.category_key + ':' + self.name
98        elif self.type == self.TAG:
99            return self.tag.original_name
100        return ''
101
102    def break_cycles(self):
103        del self.parent
104        del self.children
105
106    def ensure_icon(self):
107        if self.icon_state_map[0] is not None:
108            return
109        if self.type == self.TAG:
110            if self.tag.category == 'formats':
111                fmt = self.tag.original_name.replace('ORIGINAL_', '')
112                cc = self.file_icon_provider(fmt)
113            else:
114                cc = self.category_custom_icons.get(self.tag.category, None)
115        elif self.type == self.CATEGORY:
116            cc = self.category_custom_icons.get(self.category_key, None)
117        self.icon_state_map[0] = cc or QIcon()
118
119    def __str__(self):
120        if self.type == self.ROOT:
121            return 'ROOT'
122        if self.type == self.CATEGORY:
123            return 'CATEGORY(category_key={!r}, name={!r}, num_children={!r}, temp={!r})'.format(
124                self.category_key, self.name, len(self.children), self.temporary)
125        return 'TAG(name={!r}), temp={!r})'.format(self.tag.name, self.temporary)
126
127    def row(self):
128        if self.parent is not None:
129            return self.parent.children.index(self)
130        return 0
131
132    def append(self, child):
133        child.parent = self
134        self.children.append(child)
135
136    @property
137    def average_rating(self):
138        if self.type != self.TAG:
139            return 0
140        if not self.tag.is_hierarchical:
141            return self.tag.avg_rating
142        if not self.children:
143            return self.tag.avg_rating  # leaf node, avg_rating is correct
144        if self.cached_average_rating is None:
145            raise ValueError('Must compute average rating for tag ' + self.tag.original_name)
146        return self.cached_average_rating
147
148    @property
149    def item_count(self):
150        if not self.tag.is_hierarchical or not self.children:
151            return self.tag.count
152        if self.cached_item_count is not None:
153            return self.cached_item_count
154
155        def child_item_set(node):
156            s = node.tag.id_set.copy()
157            for child in node.children:
158                s |= child_item_set(child)
159            return s
160        self.cached_item_count = len(child_item_set(self))
161        return self.cached_item_count
162
163    def data(self, role):
164        if role == Qt.ItemDataRole.UserRole:
165            return self
166        if self.type == self.TAG:
167            return self.tag_data(role)
168        if self.type == self.CATEGORY:
169            return self.category_data(role)
170        return None
171
172    def category_data(self, role):
173        if role == Qt.ItemDataRole.DisplayRole:
174            return self.py_name
175        if role == Qt.ItemDataRole.EditRole:
176            return (self.py_name)
177        if role == Qt.ItemDataRole.DecorationRole:
178            if not self.tag.state:
179                self.ensure_icon()
180            return self.icon_state_map[self.tag.state]
181        if role == Qt.ItemDataRole.FontRole:
182            return bf()
183        if role == Qt.ItemDataRole.ToolTipRole:
184            return self.tooltip if gprefs['tag_browser_show_tooltips'] else None
185        if role == DRAG_IMAGE_ROLE:
186            self.ensure_icon()
187            return self.icon_state_map[0]
188        if role == COUNT_ROLE:
189            return len(self.child_tags())
190        return None
191
192    def tag_data(self, role):
193        tag = self.tag
194        if tag.use_sort_as_name:
195            name = tag.sort
196        else:
197            if not tag.is_hierarchical:
198                name = tag.original_name
199            else:
200                name = tag.name
201        if role == Qt.ItemDataRole.DisplayRole:
202            return str(name)
203        if role == Qt.ItemDataRole.EditRole:
204            return (tag.original_name)
205        if role == Qt.ItemDataRole.DecorationRole:
206            if not tag.state:
207                self.ensure_icon()
208            return self.icon_state_map[tag.state]
209        if role == Qt.ItemDataRole.ToolTipRole:
210            if gprefs['tag_browser_show_tooltips']:
211                tt = [self.tooltip] if self.tooltip else []
212                if tag.original_categories:
213                    tt.append('%s:%s' % (','.join(tag.original_categories), tag.original_name))
214                else:
215                    tt.append('%s:%s' % (tag.category, tag.original_name))
216                ar = self.average_rating
217                if ar:
218                    tt.append(_('Average rating for books in this category: %.1f') % ar)
219                elif self.type == self.TAG and ar is not None and self.tag.category != 'search':
220                    tt.append(_('Books in this category are unrated'))
221                if self.type == self.TAG and self.tag.category == 'search':
222                    tt.append(_('Search expression:') + ' ' + self.tag.search_expression)
223                if self.type == self.TAG and self.tag.category != 'search':
224                    tt.append(_('Number of books: %s') % self.item_count)
225                return '\n'.join(tt)
226            return None
227        if role == DRAG_IMAGE_ROLE:
228            self.ensure_icon()
229            return self.icon_state_map[0]
230        if role == COUNT_ROLE:
231            return self.item_count
232        return None
233
234    def dump_data(self):
235        fmt = '%s [count=%s%s]'
236        if self.type == self.CATEGORY:
237            return fmt % (self.py_name, len(self.child_tags()), '')
238        tag = self.tag
239        if tag.use_sort_as_name:
240            name = tag.sort
241        else:
242            if not tag.is_hierarchical:
243                name = tag.original_name
244            else:
245                name = tag.name
246        count = self.item_count
247        rating = self.average_rating
248        if rating:
249            rating = ',rating=%.1f' % rating
250        return fmt % (name, count, rating or '')
251
252    def toggle(self, set_to=None):
253        '''
254        set_to: None => advance the state, otherwise a value from TAG_SEARCH_STATES
255        '''
256        if set_to is None:
257            while True:
258                self.tag.state = (self.tag.state + 1)%5
259                if self.tag.state == TAG_SEARCH_STATES['mark_plus'] or \
260                        self.tag.state == TAG_SEARCH_STATES['mark_minus']:
261                    if self.tag.is_searchable:
262                        break
263                elif self.tag.state == TAG_SEARCH_STATES['mark_plusplus'] or\
264                        self.tag.state == TAG_SEARCH_STATES['mark_minusminus']:
265                    if self.tag.is_searchable and len(self.children) and \
266                                    self.tag.is_hierarchical == '5state':
267                        break
268                else:
269                    break
270        else:
271            self.tag.state = set_to
272
273    def all_children(self):
274        res = []
275
276        def recurse(nodes, res):
277            for t in nodes:
278                res.append(t)
279                recurse(t.children, res)
280        recurse(self.children, res)
281        return res
282
283    def child_tags(self):
284        res = []
285
286        def recurse(nodes, res, depth):
287            if depth > 100:
288                return
289            for t in nodes:
290                if t.type != TagTreeItem.CATEGORY:
291                    res.append(t)
292                recurse(t.children, res, depth+1)
293        recurse(self.children, res, 1)
294        return res
295    # }}}
296
297
298FL_Interval = namedtuple('FL_Interval', ('first_chr', 'last_chr', 'length'))
299
300
301def rename_only_in_vl_question(parent):
302    return question_dialog(parent,
303                           _('Rename in Virtual library'), '<p>' +
304                           _('Do you want this rename to apply only to books '
305                             'in the current Virtual library?') + '</p>',
306                           yes_text=_('Yes, apply only in VL'),
307                           no_text=_('No, apply in entire library'))
308
309
310class TagsModel(QAbstractItemModel):  # {{{
311
312    search_item_renamed = pyqtSignal()
313    tag_item_renamed = pyqtSignal()
314    refresh_required = pyqtSignal()
315    restriction_error = pyqtSignal(object)
316    drag_drop_finished = pyqtSignal(object)
317    user_categories_edited = pyqtSignal(object, object)
318    user_category_added = pyqtSignal()
319    show_error_after_event_loop_tick_signal = pyqtSignal(object, object, object)
320    convert_requested = pyqtSignal(object, object)
321
322    def __init__(self, parent, prefs=gprefs):
323        QAbstractItemModel.__init__(self, parent)
324        self.use_position_based_index_on_next_recount = False
325        self.prefs = prefs
326        self.node_map = {}
327        self.category_nodes = []
328        self.category_custom_icons = {}
329        for k, v in iteritems(self.prefs['tags_browser_category_icons']):
330            icon = QIcon(os.path.join(config_dir, 'tb_icons', v))
331            if len(icon.availableSizes()) > 0:
332                self.category_custom_icons[k] = icon
333        self.categories_with_ratings = ['authors', 'series', 'publisher', 'tags']
334        self.icon_state_map = [None, QIcon(I('plus.png')), QIcon(I('plusplus.png')),
335                             QIcon(I('minus.png')), QIcon(I('minusminus.png'))]
336
337        self.hidden_categories = set()
338        self.search_restriction = None
339        self.filter_categories_by = None
340        self.collapse_model = 'disable'
341        self.row_map = []
342        self.root_item = self.create_node(icon_map=self.icon_state_map)
343        self.db = None
344        self._build_in_progress = False
345        self.reread_collapse_model({}, rebuild=False)
346        self.show_error_after_event_loop_tick_signal.connect(self.on_show_error_after_event_loop_tick, type=Qt.ConnectionType.QueuedConnection)
347
348    @property
349    def gui_parent(self):
350        return QObject.parent(self)
351
352    def set_custom_category_icon(self, key, path):
353        d = self.prefs['tags_browser_category_icons']
354        if path:
355            d[key] = path
356            self.category_custom_icons[key] = QIcon(os.path.join(config_dir,
357                                                            'tb_icons', path))
358        else:
359            if key in d:
360                path = os.path.join(config_dir, 'tb_icons', d[key])
361                try:
362                    os.remove(path)
363                except:
364                    pass
365            del d[key]
366            del self.category_custom_icons[key]
367        self.prefs['tags_browser_category_icons'] = d
368
369    def reread_collapse_model(self, state_map, rebuild=True):
370        if self.prefs['tags_browser_collapse_at'] == 0:
371            self.collapse_model = 'disable'
372        else:
373            self.collapse_model = self.prefs['tags_browser_partition_method']
374        if rebuild:
375            self.rebuild_node_tree(state_map)
376
377    def set_database(self, db, hidden_categories=None):
378        self.beginResetModel()
379        hidden_cats = db.new_api.pref('tag_browser_hidden_categories', None)
380        # migrate from config to db prefs
381        if hidden_cats is None:
382            hidden_cats = config['tag_browser_hidden_categories']
383        self.hidden_categories = set()
384        # strip out any non-existence field keys
385        for cat in hidden_cats:
386            if cat in db.field_metadata:
387                self.hidden_categories.add(cat)
388        db.new_api.set_pref('tag_browser_hidden_categories', list(self.hidden_categories))
389        if hidden_categories is not None:
390            self.hidden_categories = hidden_categories
391
392        self.db = db
393        self._run_rebuild()
394        self.endResetModel()
395
396    def rebuild_node_tree(self, state_map={}):
397        if self._build_in_progress:
398            print('Tag browser build already in progress')
399            traceback.print_stack()
400            return
401        # traceback.print_stack()
402        # print ()
403        self._build_in_progress = True
404        self.beginResetModel()
405        self._run_rebuild(state_map=state_map)
406        self.endResetModel()
407        self._build_in_progress = False
408
409    def _run_rebuild(self, state_map={}):
410        for node in itervalues(self.node_map):
411            node.break_cycles()
412        del node  # Clear reference to node in the current frame
413        self.node_map.clear()
414        self.category_nodes = []
415        self.hierarchical_categories = {}
416        self.root_item = self.create_node(icon_map=self.icon_state_map)
417        self._rebuild_node_tree(state_map=state_map)
418
419    def _rebuild_node_tree(self, state_map):
420        # Note that _get_category_nodes can indirectly change the
421        # user_categories dict.
422        data = self._get_category_nodes(config['sort_tags_by'])
423        gst = self.db.new_api.pref('grouped_search_terms', {})
424
425        last_category_node = None
426        category_node_map = {}
427        self.user_category_node_tree = {}
428
429        # We build the node tree including categories that might later not be
430        # displayed because their items might be in User categories. The resulting
431        # nodes will be reordered later.
432        for i, key in enumerate(self.categories):
433            is_gst = False
434            if key.startswith('@') and key[1:] in gst:
435                tt = _('The grouped search term name is "{0}"').format(key)
436                is_gst = True
437            elif key == 'news':
438                tt = ''
439            else:
440                cust_desc = ''
441                fm = self.db.field_metadata[key]
442                if fm['is_custom']:
443                    cust_desc = fm['display'].get('description', '')
444                    if cust_desc:
445                        cust_desc = '\n' + _('Description:') + ' ' + cust_desc
446                tt = _('The lookup/search name is "{0}"{1}').format(key, cust_desc)
447
448            if self.category_custom_icons.get(key, None) is None:
449                self.category_custom_icons[key] = QIcon(I(
450                    category_icon_map['gst'] if is_gst else category_icon_map.get(
451                        key, (category_icon_map['user:'] if key.startswith('@') else category_icon_map['custom:']))))
452
453            if key.startswith('@'):
454                path_parts = [p for p in key.split('.')]
455                path = ''
456                last_category_node = self.root_item
457                tree_root = self.user_category_node_tree
458                for i,p in enumerate(path_parts):
459                    path += p
460                    if path not in category_node_map:
461                        node = self.create_node(parent=last_category_node,
462                                   data=p[1:] if i == 0 else p,
463                                   is_category=True,
464                                   tooltip=tt if path == key else path,
465                                   category_key=path,
466                                   icon_map=self.icon_state_map)
467                        last_category_node = node
468                        category_node_map[path] = node
469                        self.category_nodes.append(node)
470                        node.can_be_edited = (not is_gst) and (i == (len(path_parts)-1))
471                        node.is_gst = is_gst
472                        if not is_gst:
473                            node.tag.is_hierarchical = '5state'
474                            tree_root[p] = {}
475                            tree_root = tree_root[p]
476                    else:
477                        last_category_node = category_node_map[path]
478                        tree_root = tree_root[p]
479                    path += '.'
480            else:
481                node = self.create_node(parent=self.root_item,
482                                   data=self.categories[key],
483                                   is_category=True,
484                                   tooltip=tt, category_key=key,
485                                   icon_map=self.icon_state_map)
486                node.is_gst = False
487                category_node_map[key] = node
488                last_category_node = node
489                self.category_nodes.append(node)
490        self._create_node_tree(data, state_map)
491
492    def _create_node_tree(self, data, state_map):
493        sort_by = config['sort_tags_by']
494
495        eval_formatter = EvalFormatter()
496        intermediate_nodes = {}
497
498        if data is None:
499            print('_create_node_tree: no data!')
500            traceback.print_stack()
501            return
502
503        collapse = self.prefs['tags_browser_collapse_at']
504        collapse_model = self.collapse_model
505        if collapse == 0:
506            collapse_model = 'disable'
507        elif collapse_model != 'disable':
508            if sort_by == 'name':
509                collapse_template = tweaks['categories_collapsed_name_template']
510            elif sort_by == 'rating':
511                collapse_model = 'partition'
512                collapse_template = tweaks['categories_collapsed_rating_template']
513            else:
514                collapse_model = 'partition'
515                collapse_template = tweaks['categories_collapsed_popularity_template']
516
517        def get_name_components(name):
518            components = [t.strip() for t in name.split('.') if t.strip()]
519            if len(components) == 0 or '.'.join(components) != name:
520                components = [name]
521            return components
522
523        def process_one_node(category, collapse_model, book_rating_map, state_map):  # {{{
524            collapse_letter = None
525            key = category.category_key
526            is_gst = category.is_gst
527            if key not in data:
528                return
529            if key in self.prefs['tag_browser_dont_collapse']:
530                collapse_model = 'disable'
531            cat_len = len(data[key])
532            if cat_len <= 0:
533                return
534
535            category_child_map = {}
536            fm = self.db.field_metadata[key]
537            clear_rating = True if key not in self.categories_with_ratings and \
538                                not fm['is_custom'] and \
539                                not fm['kind'] == 'user' \
540                            else False
541            in_uc = fm['kind'] == 'user' and not is_gst
542            tt = key if in_uc else None
543
544            if collapse_model == 'first letter':
545                # Build a list of 'equal' first letters by noticing changes
546                # in ICU's 'ordinal' for the first letter. In this case, the
547                # first letter can actually be more than one letter long.
548                fl_collapse_when = self.prefs['tags_browser_collapse_fl_at']
549                fl_collapse = True if fl_collapse_when > 1 else False
550                intervals = []
551                cl_list = [None] * len(data[key])
552                last_ordnum = 0
553                last_c = ' '
554                last_idx = 0
555                for idx,tag in enumerate(data[key]):
556                    # Deal with items that don't have sorts, such as formats
557                    t = tag.sort if tag.sort else tag.name
558                    c = icu_upper(t) if t else ' '
559                    ordnum, ordlen = collation_order_for_partitioning(c)
560                    if last_ordnum != ordnum:
561                        if fl_collapse and idx > 0:
562                            intervals.append(FL_Interval(last_c, last_c, idx-last_idx))
563                            last_idx = idx
564                        last_c = c[0:ordlen]
565                        last_ordnum = ordnum
566                    cl_list[idx] = last_c
567                if fl_collapse:
568                    intervals.append(FL_Interval(last_c, last_c, len(cl_list)-last_idx))
569                    # Combine together first letter categories that are smaller
570                    # than the specified option. We choose which item to combine
571                    # by the size of the items before and after, privileging making
572                    # smaller categories. Loop through the intervals doing the combine
573                    # until nothing changes. Multiple iterations are required because
574                    # we might need to combine categories that are already combined.
575                    fl_intervals_changed = True
576                    null_interval = FL_Interval('', '', 100000000)
577                    while fl_intervals_changed and len(intervals) > 1:
578                        fl_intervals_changed = False
579                        for idx,interval in enumerate(intervals):
580                            if interval.length >= fl_collapse_when:
581                                continue
582                            prev = next_ = null_interval
583                            if idx == 0:
584                                next_ = intervals[idx+1]
585                            else:
586                                prev = intervals[idx-1]
587                                if idx < len(intervals) - 1:
588                                    next_ = intervals[idx+1]
589                            if prev.length < next_.length:
590                                intervals[idx-1] = FL_Interval(prev.first_chr,
591                                                               interval.last_chr,
592                                                               prev.length + interval.length)
593                            else:
594                                intervals[idx+1] = FL_Interval(interval.first_chr,
595                                                               next_.last_chr,
596                                                               next_.length + interval.length)
597                            del intervals[idx]
598                            fl_intervals_changed = True
599                            break
600                    # Now correct the first letter list, entering either the letter
601                    # or the range for each item in the category. If we ended up
602                    # with only one 'first letter' category then don't combine
603                    # letters and revert to basic 'by first letter'
604                    if len(intervals) > 1:
605                        cur_idx = 0
606                        for interval in intervals:
607                            first_chr, last_chr, length = interval
608                            for i in range(0, length):
609                                if first_chr == last_chr:
610                                    cl_list[cur_idx] = first_chr
611                                else:
612                                    cl_list[cur_idx] = '{} - {}'.format(first_chr, last_chr)
613                                cur_idx += 1
614            top_level_component = 'z' + data[key][0].original_name
615
616            last_idx = -collapse
617            category_is_hierarchical = self.is_key_a_hierarchical_category(key)
618
619            for idx,tag in enumerate(data[key]):
620                components = None
621                if clear_rating:
622                    tag.avg_rating = None
623                tag.state = state_map.get((tag.name, tag.category), 0)
624
625                if collapse_model != 'disable' and cat_len > collapse:
626                    if collapse_model == 'partition':
627                        # Only partition at the top level. This means that we must
628                        # not do a break until the outermost component changes.
629                        if idx >= last_idx + collapse and \
630                                 not tag.original_name.startswith(top_level_component+'.'):
631                            if cat_len > idx + collapse:
632                                last = idx + collapse - 1
633                            else:
634                                last = cat_len - 1
635                            if category_is_hierarchical:
636                                ct = copy.copy(data[key][last])
637                                components = get_name_components(ct.original_name)
638                                ct.sort = ct.name = components[0]
639                                d = {'last': ct}
640                                # Do the first node after the last node so that
641                                # the components array contains the right values
642                                # to be used later
643                                ct2 = copy.copy(tag)
644                                components = get_name_components(ct2.original_name)
645                                ct2.sort = ct2.name = components[0]
646                                d['first'] = ct2
647                            else:
648                                d = {'first': tag}
649                                # Some nodes like formats and identifiers don't
650                                # have sort set. Fix that so the template will work
651                                if d['first'].sort is None:
652                                    d['first'].sort = tag.name
653                                d['last'] = data[key][last]
654                                if d['last'].sort is None:
655                                    d['last'].sort = data[key][last].name
656
657                            name = eval_formatter.safe_format(collapse_template,
658                                                        d, '##TAG_VIEW##', None)
659                            if name.startswith('##TAG_VIEW##'):
660                                # Formatter threw an exception. Don't create subnode
661                                node_parent = sub_cat = category
662                            else:
663                                sub_cat = self.create_node(parent=category, data=name,
664                                     tooltip=None, temporary=True,
665                                     is_category=True,
666                                     category_key=category.category_key,
667                                     icon_map=self.icon_state_map)
668                                sub_cat.tag.is_searchable = False
669                                sub_cat.is_gst = is_gst
670                                node_parent = sub_cat
671                            last_idx = idx  # remember where we last partitioned
672                        else:
673                            node_parent = sub_cat
674                    else:  # by 'first letter'
675                        cl = cl_list[idx]
676                        if cl != collapse_letter:
677                            collapse_letter = cl
678                            sub_cat = self.create_node(parent=category,
679                                     data=collapse_letter,
680                                     is_category=True,
681                                     tooltip=None, temporary=True,
682                                     category_key=category.category_key,
683                                     icon_map=self.icon_state_map)
684                        sub_cat.is_gst = is_gst
685                        node_parent = sub_cat
686                else:
687                    node_parent = category
688
689                # category display order is important here. The following works
690                # only if all the non-User categories are displayed before the
691                # User categories
692                if category_is_hierarchical or tag.is_hierarchical:
693                    components = get_name_components(tag.original_name)
694                else:
695                    components = [tag.original_name]
696
697                if (not tag.is_hierarchical) and (in_uc or
698                        (fm['is_custom'] and fm['display'].get('is_names', False)) or
699                        not category_is_hierarchical or len(components) == 1):
700                    n = self.create_node(parent=node_parent, data=tag, tooltip=tt,
701                                    icon_map=self.icon_state_map)
702                    category_child_map[tag.name, tag.category] = n
703                else:
704                    for i,comp in enumerate(components):
705                        if i == 0:
706                            child_map = category_child_map
707                            top_level_component = comp
708                        else:
709                            child_map = {(t.tag.name, t.tag.category): t
710                                        for t in node_parent.children
711                                            if t.type != TagTreeItem.CATEGORY}
712                        if (comp,tag.category) in child_map:
713                            node_parent = child_map[(comp,tag.category)]
714                            t = node_parent.tag
715                            t.is_hierarchical = '5state' if tag.category != 'search' else '3state'
716                            if tag.id_set is not None and t.id_set is not None:
717                                t.id_set = t.id_set | tag.id_set
718                            intermediate_nodes[t.original_name, t.category] = t
719                        else:
720                            if i < len(components)-1:
721                                original_name = '.'.join(components[:i+1])
722                                t = intermediate_nodes.get((original_name, tag.category), None)
723                                if t is None:
724                                    t = copy.copy(tag)
725                                    t.original_name = original_name
726                                    t.count = 0
727                                    if key != 'search':
728                                        # This 'manufactured' intermediate node can
729                                        # be searched, but cannot be edited.
730                                        t.is_editable = False
731                                    else:
732                                        t.is_searchable = t.is_editable = False
733                                    intermediate_nodes[original_name, tag.category] = t
734                            else:
735                                t = tag
736                                if not in_uc:
737                                    t.original_name = t.name
738                                intermediate_nodes[t.original_name, t.category] = t
739                            t.is_hierarchical = \
740                                '5state' if t.category != 'search' else '3state'
741                            t.name = comp
742                            node_parent = self.create_node(parent=node_parent, data=t,
743                                            tooltip=tt, icon_map=self.icon_state_map)
744                            child_map[(comp,tag.category)] = node_parent
745
746                        # Correct the average rating for the node
747                        total = count = 0
748                        for book_id in t.id_set:
749                            rating = book_rating_map.get(book_id, 0)
750                            if rating:
751                                total += rating/2.0
752                                count += 1
753                        node_parent.cached_average_rating = float(total)/count if total and count else 0
754            return
755        # }}}
756
757        # Build the entire node tree. Note that category_nodes is in field
758        # metadata order so the User categories will be at the end
759        with self.db.new_api.safe_read_lock:  # needed as we read from book_value_map
760            for category in self.category_nodes:
761                process_one_node(category, collapse_model, self.db.new_api.fields['rating'].book_value_map,
762                                state_map.get(category.category_key, {}))
763
764        # Fix up the node tree, reordering as needed and deleting undisplayed nodes
765        new_children = []
766        for node in self.root_item.children:
767            key = node.category_key
768            if key in self.row_map:
769                if self.prefs['tag_browser_hide_empty_categories'] and len(node.child_tags()) == 0:
770                    continue
771                if self.hidden_categories:
772                    if key in self.hidden_categories:
773                        continue
774                    found = False
775                    for cat in self.hidden_categories:
776                        if cat.startswith('@') and key.startswith(cat + '.'):
777                            found = True
778                    if found:
779                        continue
780                new_children.append(node)
781        self.root_item.children = new_children
782        self.root_item.children.sort(key=lambda x: self.row_map.index(x.category_key))
783
784    def get_category_editor_data(self, category):
785        for cat in self.root_item.children:
786            if cat.category_key == category:
787                return [(t.tag.id, t.tag.original_name, t.tag.count)
788                        for t in cat.child_tags() if t.tag.count > 0]
789
790    def is_in_user_category(self, index):
791        if not index.isValid():
792            return False
793        p = self.get_node(index)
794        while p.type != TagTreeItem.CATEGORY:
795            p = p.parent
796        return p.tag.category.startswith('@')
797
798    def is_key_a_hierarchical_category(self, key):
799        result = self.hierarchical_categories.get(key)
800        if result is None:
801            result = not (
802                    key in ['authors', 'publisher', 'news', 'formats', 'rating'] or
803                    key not in self.db.new_api.pref('categories_using_hierarchy', []) or
804                    config['sort_tags_by'] != 'name')
805            self.hierarchical_categories[key] = result
806        return result
807
808    def is_index_on_a_hierarchical_category(self, index):
809        if not index.isValid():
810            return False
811        p = self.get_node(index)
812        return self.is_key_a_hierarchical_category(p.tag.category)
813
814    # Drag'n Drop {{{
815    def mimeTypes(self):
816        return ["application/calibre+from_library",
817                'application/calibre+from_tag_browser']
818
819    def mimeData(self, indexes):
820        data = []
821        for idx in indexes:
822            if idx.isValid():
823                # get some useful serializable data
824                node = self.get_node(idx)
825                path = self.path_for_index(idx)
826                if node.type == TagTreeItem.CATEGORY:
827                    d = (node.type, node.py_name, node.category_key)
828                else:
829                    t = node.tag
830                    p = node
831                    while p.type != TagTreeItem.CATEGORY:
832                        p = p.parent
833                    d = (node.type, p.category_key, p.is_gst, t.original_name,
834                         t.category, path)
835                data.append(d)
836            else:
837                data.append(None)
838        raw = bytearray(json_dumps(data))
839        ans = QMimeData()
840        ans.setData('application/calibre+from_tag_browser', raw)
841        return ans
842
843    def dropMimeData(self, md, action, row, column, parent):
844        fmts = {str(x) for x in md.formats()}
845        if not fmts.intersection(set(self.mimeTypes())):
846            return False
847        if "application/calibre+from_library" in fmts:
848            if action != Qt.DropAction.CopyAction:
849                return False
850            return self.do_drop_from_library(md, action, row, column, parent)
851        elif 'application/calibre+from_tag_browser' in fmts:
852            return self.do_drop_from_tag_browser(md, action, row, column, parent)
853
854    def do_drop_from_tag_browser(self, md, action, row, column, parent):
855        if not parent.isValid():
856            return False
857        dest = self.get_node(parent)
858        if not md.hasFormat('application/calibre+from_tag_browser'):
859            return False
860        data = bytes(md.data('application/calibre+from_tag_browser'))
861        src = json_loads(data)
862        if len(src) == 1:
863            # Check to see if this is a hierarchical rename
864            s = src[0]
865            # This check works for both hierarchical and user categories.
866            # We can drag only tag items.
867            if s[0] != TagTreeItem.TAG:
868                return False
869            src_index = self.index_for_path(s[5])
870            if src_index == parent:
871                # dropped on itself
872                return False
873            src_item = self.get_node(src_index)
874            dest_item = parent.data(Qt.ItemDataRole.UserRole)
875            # Here we do the real work. If src is a tag, src == dest, and src
876            # is hierarchical then we can do a rename.
877            if (src_item.type == TagTreeItem.TAG and
878                    src_item.tag.category == dest_item.tag.category and
879                    self.is_key_a_hierarchical_category(src_item.tag.category)):
880                key = s[1]
881                # work out the part of the source name to use in the rename
882                # It isn't necessarily a simple name but might be the remaining
883                # levels of the hierarchy
884                part = src_item.tag.original_name.rpartition('.')
885                src_simple_name = part[2]
886                # work out the new prefix, the destination node name
887                if dest.type == TagTreeItem.TAG:
888                    new_name = dest_item.tag.original_name + '.' + src_simple_name
889                else:
890                    new_name = src_simple_name
891                if self.get_in_vl():
892                    src_item.use_vl = rename_only_in_vl_question(self.gui_parent)
893                else:
894                    src_item.use_vl = False
895                self.rename_item(src_item, key, new_name)
896                return True
897        # Should be working with a user category
898        if dest.type != TagTreeItem.CATEGORY:
899            return False
900        return self.move_or_copy_item_to_user_category(src, dest, action)
901
902    def move_or_copy_item_to_user_category(self, src, dest, action):
903        '''
904        src is a list of tuples representing items to copy. The tuple is
905        (type, containing category key, category key is global search term,
906         full name, category key, path to node)
907        The type must be TagTreeItem.TAG
908        dest is the TagTreeItem node to receive the items
909        action is Qt.DropAction.CopyAction or Qt.DropAction.MoveAction
910        '''
911        def process_source_node(user_cats, src_parent, src_parent_is_gst,
912                                is_uc, dest_key, idx):
913            '''
914            Copy/move an item and all its children to the destination
915            '''
916            copied = False
917            src_name = idx.tag.original_name
918            src_cat = idx.tag.category
919            # delete the item if the source is a User category and action is move
920            if is_uc and not src_parent_is_gst and src_parent in user_cats and \
921                                    action == Qt.DropAction.MoveAction:
922                new_cat = []
923                for tup in user_cats[src_parent]:
924                    if src_name == tup[0] and src_cat == tup[1]:
925                        continue
926                    new_cat.append(list(tup))
927                user_cats[src_parent] = new_cat
928            else:
929                copied = True
930
931            # Now add the item to the destination User category
932            add_it = True
933            if not is_uc and src_cat == 'news':
934                src_cat = 'tags'
935            for tup in user_cats[dest_key]:
936                if src_name == tup[0] and src_cat == tup[1]:
937                    add_it = False
938            if add_it:
939                user_cats[dest_key].append([src_name, src_cat, 0])
940
941            for c in idx.children:
942                copied = process_source_node(user_cats, src_parent, src_parent_is_gst,
943                                             is_uc, dest_key, c)
944            return copied
945
946        user_cats = self.db.new_api.pref('user_categories', {})
947        path = None
948        for s in src:
949            src_parent, src_parent_is_gst = s[1:3]
950            path = s[5]
951
952            if src_parent.startswith('@'):
953                is_uc = True
954                src_parent = src_parent[1:]
955            else:
956                is_uc = False
957            dest_key = dest.category_key[1:]
958
959            if dest_key not in user_cats:
960                continue
961
962            idx = self.index_for_path(path)
963            if idx.isValid():
964                process_source_node(user_cats, src_parent, src_parent_is_gst,
965                                             is_uc, dest_key,
966                                             self.get_node(idx))
967
968        self.db.new_api.set_pref('user_categories', user_cats)
969        self.refresh_required.emit()
970        self.user_category_added.emit()
971        return True
972
973    def do_drop_from_library(self, md, action, row, column, parent):
974        idx = parent
975        if idx.isValid():
976            node = self.data(idx, Qt.ItemDataRole.UserRole)
977            if node.type == TagTreeItem.TAG:
978                fm = self.db.metadata_for_field(node.tag.category)
979                if node.tag.category in \
980                    ('tags', 'series', 'authors', 'rating', 'publisher', 'languages', 'formats') or \
981                    (fm['is_custom'] and (
982                            fm['datatype'] in ['text', 'rating', 'series',
983                                               'enumeration'] or (
984                                                   fm['datatype'] == 'composite' and
985                                                   fm['display'].get('make_category', False)))):
986                    mime = 'application/calibre+from_library'
987                    ids = list(map(int, md.data(mime).data().split()))
988                    self.handle_drop(node, ids)
989                    return True
990            elif node.type == TagTreeItem.CATEGORY:
991                fm_dest = self.db.metadata_for_field(node.category_key)
992                if fm_dest['kind'] == 'user':
993                    fm_src = self.db.metadata_for_field(md.column_name)
994                    if md.column_name in ['authors', 'publisher', 'series'] or \
995                            (fm_src['is_custom'] and (
996                             fm_src['datatype'] in ['series', 'text', 'enumeration'] and
997                              not fm_src['is_multiple'])or
998                             (fm_src['datatype'] == 'composite' and
999                              fm_src['display'].get('make_category', False))):
1000                        mime = 'application/calibre+from_library'
1001                        ids = list(map(int, md.data(mime).data().split()))
1002                        self.handle_user_category_drop(node, ids, md.column_name)
1003                        return True
1004        return False
1005
1006    def handle_user_category_drop(self, on_node, ids, column):
1007        categories = self.db.new_api.pref('user_categories', {})
1008        cat_contents = categories.get(on_node.category_key[1:], None)
1009        if cat_contents is None:
1010            return
1011        cat_contents = {(v, c) for v,c,ign in cat_contents}
1012
1013        fm_src = self.db.metadata_for_field(column)
1014        label = fm_src['label']
1015
1016        for id in ids:
1017            if not fm_src['is_custom']:
1018                if label == 'authors':
1019                    value = self.db.authors(id, index_is_id=True)
1020                    value = [v.replace('|', ',') for v in value.split(',')]
1021                elif label == 'publisher':
1022                    value = self.db.publisher(id, index_is_id=True)
1023                elif label == 'series':
1024                    value = self.db.series(id, index_is_id=True)
1025            else:
1026                if fm_src['datatype'] != 'composite':
1027                    value = self.db.get_custom(id, label=label, index_is_id=True)
1028                else:
1029                    value = self.db.get_property(id, loc=fm_src['rec_index'],
1030                                                 index_is_id=True)
1031            if value:
1032                if not isinstance(value, list):
1033                    value = [value]
1034                cat_contents |= {(v, column) for v in value}
1035
1036        categories[on_node.category_key[1:]] = [[v, c, 0] for v,c in cat_contents]
1037        self.db.new_api.set_pref('user_categories', categories)
1038        self.refresh_required.emit()
1039        self.user_category_added.emit()
1040
1041    def handle_drop_on_format(self, fmt, book_ids):
1042        self.convert_requested.emit(book_ids, fmt)
1043
1044    def handle_drop(self, on_node, ids):
1045        # print 'Dropped ids:', ids, on_node.tag
1046        key = on_node.tag.category
1047        if key == 'formats':
1048            self.handle_drop_on_format(on_node.tag.name, ids)
1049            return
1050        if (key == 'authors' and len(ids) >= 5):
1051            if not confirm('<p>'+_('Changing the authors for several books can '
1052                           'take a while. Are you sure?') +
1053                           '</p>', 'tag_browser_drop_authors', self.gui_parent):
1054                return
1055        elif len(ids) > 15:
1056            if not confirm('<p>'+_('Changing the metadata for that many books '
1057                           'can take a while. Are you sure?') +
1058                           '</p>', 'tag_browser_many_changes', self.gui_parent):
1059                return
1060
1061        fm = self.db.metadata_for_field(key)
1062        is_multiple = fm['is_multiple']
1063        val = on_node.tag.original_name
1064        for id in ids:
1065            mi = self.db.get_metadata(id, index_is_id=True)
1066
1067            # Prepare to ignore the author, unless it is changed. Title is
1068            # always ignored -- see the call to set_metadata
1069            set_authors = False
1070
1071            # Author_sort cannot change explicitly. Changing the author might
1072            # change it.
1073            mi.author_sort = None  # Never will change by itself.
1074
1075            if key == 'authors':
1076                mi.authors = [val]
1077                set_authors=True
1078            elif fm['datatype'] == 'rating':
1079                mi.set(key, len(val) * 2)
1080            elif fm['datatype'] == 'series':
1081                series_index = self.db.new_api.get_next_series_num_for(val, field=key)
1082                if fm['is_custom']:
1083                    mi.set(key, val, extra=series_index)
1084                else:
1085                    mi.series, mi.series_index = val, series_index
1086            elif is_multiple:
1087                new_val = mi.get(key, [])
1088                if val in new_val:
1089                    # Fortunately, only one field can change, so the continue
1090                    # won't break anything
1091                    continue
1092                new_val.append(val)
1093                mi.set(key, new_val)
1094            else:
1095                mi.set(key, val)
1096            self.db.set_metadata(id, mi, set_title=False,
1097                                 set_authors=set_authors, commit=False)
1098        self.db.commit()
1099        self.drag_drop_finished.emit(ids)
1100    # }}}
1101
1102    def get_in_vl(self):
1103        return self.db.data.get_base_restriction() or self.db.data.get_search_restriction()
1104
1105    def get_book_ids_to_use(self):
1106        if self.db.data.get_base_restriction() or self.db.data.get_search_restriction():
1107            return self.db.search('', return_matches=True, sort_results=False)
1108        return None
1109
1110    def _get_category_nodes(self, sort):
1111        '''
1112        Called by __init__. Do not directly call this method.
1113        '''
1114        self.row_map = []
1115        self.categories = OrderedDict()
1116
1117        # Get the categories
1118        try:
1119            data = self.db.new_api.get_categories(sort=sort,
1120                    book_ids=self.get_book_ids_to_use(),
1121                    first_letter_sort=self.collapse_model == 'first letter')
1122        except Exception as e:
1123            traceback.print_exc()
1124            data = self.db.new_api.get_categories(sort=sort,
1125                    first_letter_sort=self.collapse_model == 'first letter')
1126            self.restriction_error.emit(str(e))
1127
1128        if self.filter_categories_by:
1129            if self.filter_categories_by.startswith('='):
1130                use_exact_match = True
1131                filter_by = self.filter_categories_by[1:]
1132            else:
1133                use_exact_match = False
1134                filter_by = self.filter_categories_by
1135            for category in data.keys():
1136                if use_exact_match:
1137                    data[category] = [t for t in data[category]
1138                        if lower(t.name) == filter_by]
1139                else:
1140                    data[category] = [t for t in data[category]
1141                        if lower(t.name).find(filter_by) >= 0]
1142
1143        # Build a dict of the keys that have data
1144        tb_categories = self.db.field_metadata
1145        for category in tb_categories:
1146            if category in data:  # The search category can come and go
1147                self.categories[category] = tb_categories[category]['name']
1148
1149        # Now build the list of fields in display order
1150        order = tweaks.get('tag_browser_category_default_sort', None)
1151        if order not in ('default', 'display_name', 'lookup_name'):
1152            print('Tweak tag_browser_category_default_sort is not valid. Ignored')
1153            order = 'default'
1154        if order == 'default':
1155            self.row_map = self.categories.keys()
1156        else:
1157            def key_func(val):
1158                if order == 'display_name':
1159                    return icu_lower(self.db.field_metadata[val]['name'])
1160                return icu_lower(val[1:] if val.startswith('#') or val.startswith('@') else val)
1161            direction = tweaks.get('tag_browser_category_default_sort_direction', None)
1162            if direction not in ('ascending', 'descending'):
1163                print('Tweak tag_browser_category_default_sort_direction is not valid. Ignored')
1164                direction = 'ascending'
1165            self.row_map = sorted(self.categories, key=key_func, reverse=direction == 'descending')
1166        try:
1167            order = tweaks['tag_browser_category_order']
1168            if not isinstance(order, dict):
1169                raise TypeError()
1170        except:
1171            print('Tweak tag_browser_category_order is not valid. Ignored')
1172            order = {'*': 100}
1173        defvalue = order.get('*', 100)
1174        self.row_map = sorted(self.row_map, key=lambda x: order.get(x, defvalue))
1175        return data
1176
1177    def set_categories_filter(self, txt):
1178        if txt:
1179            self.filter_categories_by = icu_lower(txt)
1180        else:
1181            self.filter_categories_by = None
1182
1183    def get_categories_filter(self):
1184        return self.filter_categories_by
1185
1186    def refresh(self, data=None):
1187        '''
1188        Here to trap usages of refresh in the old architecture. Can eventually
1189        be removed.
1190        '''
1191        print('TagsModel: refresh called!')
1192        traceback.print_stack()
1193        return False
1194
1195    def create_node(self, *args, **kwargs):
1196        node = TagTreeItem(*args, **kwargs)
1197        self.node_map[id(node)] = node
1198        node.category_custom_icons = self.category_custom_icons
1199        return node
1200
1201    def get_node(self, idx):
1202        ans = self.node_map.get(idx.internalId(), self.root_item)
1203        return ans
1204
1205    def createIndex(self, row, column, internal_pointer=None):
1206        idx = QAbstractItemModel.createIndex(self, row, column,
1207                id(internal_pointer))
1208        return idx
1209
1210    def category_row_map(self):
1211        return {category.category_key:row for row, category in enumerate(self.root_item.children)}
1212
1213    def index_for_category(self, name):
1214        for row, category in enumerate(self.root_item.children):
1215            if category.category_key == name:
1216                return self.index(row, 0, QModelIndex())
1217
1218    def columnCount(self, parent):
1219        return 1
1220
1221    def data(self, index, role):
1222        if not index.isValid():
1223            return None
1224        item = self.get_node(index)
1225        return item.data(role)
1226
1227    def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
1228        if not index.isValid():
1229            return False
1230        # set up to reposition at the same item. We can do this except if
1231        # working with the last item and that item is deleted, in which case
1232        # we position at the parent label
1233        val = str(value or '').strip()
1234        if not val:
1235            return self.show_error_after_event_loop_tick(_('Item is blank'),
1236                        _('An item cannot be set to nothing. Delete it instead.'))
1237        item = self.get_node(index)
1238        if item.type == TagTreeItem.CATEGORY and item.category_key.startswith('@'):
1239            if val.find('.') >= 0:
1240                return self.show_error_after_event_loop_tick(_('Rename User category'),
1241                    _('You cannot use periods in the name when '
1242                      'renaming User categories'))
1243
1244            user_cats = self.db.new_api.pref('user_categories', {})
1245            user_cat_keys_lower = [icu_lower(k) for k in user_cats]
1246            ckey = item.category_key[1:]
1247            ckey_lower = icu_lower(ckey)
1248            dotpos = ckey.rfind('.')
1249            if dotpos < 0:
1250                nkey = val
1251            else:
1252                nkey = ckey[:dotpos+1] + val
1253            nkey_lower = icu_lower(nkey)
1254
1255            if ckey == nkey:
1256                self.use_position_based_index_on_next_recount = True
1257                return True
1258
1259            for c in sorted(list(user_cats.keys()), key=sort_key):
1260                if icu_lower(c).startswith(ckey_lower):
1261                    if len(c) == len(ckey):
1262                        if strcmp(ckey, nkey) != 0 and \
1263                                nkey_lower in user_cat_keys_lower:
1264                            return self.show_error_after_event_loop_tick(_('Rename User category'),
1265                                _('The name %s is already used')%nkey)
1266                        user_cats[nkey] = user_cats[ckey]
1267                        del user_cats[ckey]
1268                    elif c[len(ckey)] == '.':
1269                        rest = c[len(ckey):]
1270                        if strcmp(ckey, nkey) != 0 and \
1271                                    icu_lower(nkey + rest) in user_cat_keys_lower:
1272                            return self.show_error_after_event_loop_tick(_('Rename User category'),
1273                                _('The name %s is already used')%(nkey+rest))
1274                        user_cats[nkey + rest] = user_cats[ckey + rest]
1275                        del user_cats[ckey + rest]
1276            self.user_categories_edited.emit(user_cats, nkey)  # Does a refresh
1277            self.use_position_based_index_on_next_recount = True
1278            return True
1279
1280        key = item.tag.category
1281        # make certain we know about the item's category
1282        if key not in self.db.field_metadata:
1283            return False
1284        if key == 'authors':
1285            if val.find('&') >= 0:
1286                return self.show_error_after_event_loop_tick(_('Invalid author name'),
1287                        _('Author names cannot contain & characters.'))
1288                return False
1289        if key == 'search':
1290            if val == str(item.data(role) or ''):
1291                return True
1292            if val in self.db.saved_search_names():
1293                return self.show_error_after_event_loop_tick(
1294                    _('Duplicate search name'), _('The saved search name %s is already used.')%val)
1295            self.use_position_based_index_on_next_recount = True
1296            self.db.saved_search_rename(str(item.data(role) or ''), val)
1297            item.tag.name = val
1298            self.search_item_renamed.emit()  # Does a refresh
1299        else:
1300            self.rename_item(item, key, val)
1301        return True
1302
1303    def show_error_after_event_loop_tick(self, title, msg, det_msg=''):
1304        self.show_error_after_event_loop_tick_signal.emit(title, msg, det_msg)
1305        return False
1306
1307    def on_show_error_after_event_loop_tick(self, title, msg, details):
1308        error_dialog(self.gui_parent, title, msg, det_msg=details, show=True)
1309
1310    def rename_item(self, item, key, to_what):
1311        def do_one_item(lookup_key, an_item, original_name, new_name, restrict_to_books):
1312            self.use_position_based_index_on_next_recount = True
1313            self.db.new_api.rename_items(lookup_key, {an_item.tag.id: new_name},
1314                                         restrict_to_book_ids=restrict_to_books)
1315            self.tag_item_renamed.emit()
1316            an_item.tag.name = new_name
1317            an_item.tag.state = TAG_SEARCH_STATES['clear']
1318            self.use_position_based_index_on_next_recount = True
1319            self.add_renamed_item_to_user_categories(lookup_key, original_name, new_name)
1320
1321        children = item.all_children()
1322        restrict_to_book_ids=self.get_book_ids_to_use() if item.use_vl else None
1323        if item.tag.is_editable and len(children) == 0:
1324            # Leaf node, just do it.
1325            do_one_item(key, item, item.tag.original_name, to_what, restrict_to_book_ids)
1326        else:
1327            # Middle node of a hierarchy
1328            search_name = item.tag.original_name
1329            # Clear any search icons on the original tag
1330            if item.parent.type == TagTreeItem.TAG:
1331                item.parent.tag.state = TAG_SEARCH_STATES['clear']
1332            # It might also be a leaf
1333            if item.tag.is_editable:
1334                do_one_item(key, item, item.tag.original_name, to_what, restrict_to_book_ids)
1335            # Now do the children
1336            for child_item in children:
1337                from calibre.utils.icu import startswith
1338                if (child_item.tag.is_editable and
1339                        startswith(child_item.tag.original_name, search_name)):
1340                    new_name = to_what + child_item.tag.original_name[len(search_name):]
1341                    do_one_item(key, child_item, child_item.tag.original_name,
1342                                new_name, restrict_to_book_ids)
1343        self.clean_items_from_user_categories()
1344        self.refresh_required.emit()
1345
1346    def rename_item_in_all_user_categories(self, item_name, item_category, new_name):
1347        '''
1348        Search all User categories for items named item_name with category
1349        item_category and rename them to new_name. The caller must arrange to
1350        redisplay the tree as appropriate.
1351        '''
1352        user_cats = self.db.new_api.pref('user_categories', {})
1353        for k in user_cats.keys():
1354            new_contents = []
1355            for tup in user_cats[k]:
1356                if tup[0] == item_name and tup[1] == item_category:
1357                    new_contents.append([new_name, item_category, 0])
1358                else:
1359                    new_contents.append(tup)
1360            user_cats[k] = new_contents
1361        self.db.new_api.set_pref('user_categories', user_cats)
1362
1363    def delete_item_from_all_user_categories(self, item_name, item_category):
1364        '''
1365        Search all User categories for items named item_name with category
1366        item_category and delete them. The caller must arrange to redisplay the
1367        tree as appropriate.
1368        '''
1369        user_cats = self.db.new_api.pref('user_categories', {})
1370        for cat in user_cats.keys():
1371            self.delete_item_from_user_category(cat, item_name, item_category,
1372                                                user_categories=user_cats)
1373        self.db.new_api.set_pref('user_categories', user_cats)
1374
1375    def delete_item_from_user_category(self, category, item_name, item_category,
1376                                       user_categories=None):
1377        if user_categories is not None:
1378            user_cats = user_categories
1379        else:
1380            user_cats = self.db.new_api.pref('user_categories', {})
1381        new_contents = []
1382        for tup in user_cats[category]:
1383            if tup[0] != item_name or tup[1] != item_category:
1384                new_contents.append(tup)
1385        user_cats[category] = new_contents
1386        if user_categories is None:
1387            self.db.new_api.set_pref('user_categories', user_cats)
1388
1389    def add_renamed_item_to_user_categories(self, lookup_key, original_name, new_name):
1390        '''
1391        Add new_name to any user category that contains original name if new_name
1392        isn't already there. The original name isn't deleted. This is the first
1393        step when renaming user categories that might be in virtual libraries
1394        because when finished both names may still exist. You should call
1395        clean_items_from_user_categories() when done to remove any keys that no
1396        longer exist from all user categories. The caller must arrange to
1397        redisplay the tree as appropriate.
1398        '''
1399        user_cats = self.db.new_api.pref('user_categories', {})
1400        for cat in user_cats.keys():
1401            found_original = False
1402            found_new = False
1403            for name,key,_ in user_cats[cat]:
1404                if key == lookup_key:
1405                    if name == original_name:
1406                        found_original = True
1407                    if name == new_name:
1408                        found_new = True
1409            if found_original and not found_new:
1410                user_cats[cat].append([new_name, lookup_key, 0])
1411        self.db.new_api.set_pref('user_categories', user_cats)
1412
1413    def clean_items_from_user_categories(self):
1414        '''
1415        Remove any items that no longer exist from user categories. This can
1416        happen when renaming items in virtual libraries, where sometimes the
1417        old name still exists on some book not in the VL and sometimes it
1418        doesn't. The caller must arrange to redisplay the tree as appropriate.
1419        '''
1420        user_cats = self.db.new_api.pref('user_categories', {})
1421        cache = self.db.new_api
1422        all_cats = {}
1423        for cat in user_cats.keys():
1424            new_cat = []
1425            for val, key, _ in user_cats[cat]:
1426                datatype = cache.field_metadata.get(key, {}).get('datatype', '*****')
1427                if datatype != 'composite':
1428                    id_ = cache.get_item_id(key, val)
1429                    v = cache.books_for_field(key, id_)
1430                    if v:
1431                        new_cat.append([val, key, 0])
1432            if new_cat:
1433                all_cats[cat] = new_cat
1434        self.db.new_api.set_pref('user_categories', all_cats)
1435
1436    def headerData(self, *args):
1437        return None
1438
1439    def flags(self, index, *args):
1440        ans = Qt.ItemFlag.ItemIsEnabled|Qt.ItemFlag.ItemIsEditable
1441        if index.isValid():
1442            node = self.data(index, Qt.ItemDataRole.UserRole)
1443            if node.type == TagTreeItem.TAG:
1444                tag = node.tag
1445                category = tag.category
1446                if (tag.is_editable or tag.is_hierarchical) and category != 'search':
1447                    ans |= Qt.ItemFlag.ItemIsDragEnabled
1448                fm = self.db.metadata_for_field(category)
1449                if category in \
1450                    ('tags', 'series', 'authors', 'rating', 'publisher', 'languages', 'formats') or \
1451                    (fm['is_custom'] and
1452                        fm['datatype'] in ['text', 'rating', 'series', 'enumeration']):
1453                    ans |= Qt.ItemFlag.ItemIsDropEnabled
1454            else:
1455                if node.type != TagTreeItem.CATEGORY or node.category_key != 'formats':
1456                    ans |= Qt.ItemFlag.ItemIsDropEnabled
1457        return ans
1458
1459    def supportedDropActions(self):
1460        return Qt.DropAction.CopyAction|Qt.DropAction.MoveAction
1461
1462    def named_path_for_index(self, index):
1463        ans = []
1464        while index.isValid():
1465            node = self.get_node(index)
1466            if node is self.root_item:
1467                break
1468            ans.append(node.name_id)
1469            index = self.parent(index)
1470        return ans
1471
1472    def index_for_named_path(self, named_path):
1473        parent = self.root_item
1474        ipath = []
1475        path = named_path[:]
1476        while path:
1477            q = path.pop()
1478            for i, c in enumerate(parent.children):
1479                if c.name_id == q:
1480                    ipath.append(i)
1481                    parent = c
1482                    break
1483            else:
1484                break
1485        return self.index_for_path(ipath)
1486
1487    def path_for_index(self, index):
1488        ans = []
1489        while index.isValid():
1490            ans.append(index.row())
1491            index = self.parent(index)
1492        ans.reverse()
1493        return ans
1494
1495    def index_for_path(self, path):
1496        parent = QModelIndex()
1497        for idx,v in enumerate(path):
1498            tparent = self.index(v, 0, parent)
1499            if not tparent.isValid():
1500                if v > 0 and idx == len(path) - 1:
1501                    # Probably the last item went away. Use the one before it
1502                    tparent = self.index(v-1, 0, parent)
1503                    if not tparent.isValid():
1504                        # Not valid. Use the last valid index
1505                        break
1506                else:
1507                    # There isn't one before it. Use the last valid index
1508                    break
1509            parent = tparent
1510        return parent
1511
1512    def index(self, row, column, parent):
1513        if not self.hasIndex(row, column, parent):
1514            return QModelIndex()
1515
1516        if not parent.isValid():
1517            parent_item = self.root_item
1518        else:
1519            parent_item = self.get_node(parent)
1520
1521        try:
1522            child_item = parent_item.children[row]
1523        except IndexError:
1524            return QModelIndex()
1525
1526        ans = self.createIndex(row, column, child_item)
1527        return ans
1528
1529    def parent(self, index):
1530        if not index.isValid():
1531            return QModelIndex()
1532
1533        child_item = self.get_node(index)
1534        parent_item = getattr(child_item, 'parent', None)
1535
1536        if parent_item is self.root_item or parent_item is None:
1537            return QModelIndex()
1538
1539        ans = self.createIndex(parent_item.row(), 0, parent_item)
1540        if not ans.isValid():
1541            return QModelIndex()
1542        return ans
1543
1544    def rowCount(self, parent):
1545        if parent.column() > 0:
1546            return 0
1547
1548        if not parent.isValid():
1549            parent_item = self.root_item
1550        else:
1551            parent_item = self.get_node(parent)
1552
1553        return len(parent_item.children)
1554
1555    def reset_all_states(self, except_=None):
1556        update_list = []
1557
1558        def process_tag(tag_item):
1559            tag = tag_item.tag
1560            if tag is except_:
1561                tag_index = self.createIndex(tag_item.row(), 0, tag_item)
1562                self.dataChanged.emit(tag_index, tag_index)
1563            elif tag.state != 0 or tag in update_list:
1564                tag_index = self.createIndex(tag_item.row(), 0, tag_item)
1565                tag.state = 0
1566                update_list.append(tag)
1567                self.dataChanged.emit(tag_index, tag_index)
1568            for t in tag_item.children:
1569                process_tag(t)
1570
1571        for t in self.root_item.children:
1572            process_tag(t)
1573
1574    def clear_state(self):
1575        self.reset_all_states()
1576
1577    def toggle(self, index, exclusive, set_to=None):
1578        '''
1579        exclusive: clear all states before applying this one
1580        set_to: None => advance the state, otherwise a value from TAG_SEARCH_STATES
1581        '''
1582        if not index.isValid():
1583            return False
1584        item = self.get_node(index)
1585        item.toggle(set_to=set_to)
1586        if exclusive:
1587            self.reset_all_states(except_=item.tag)
1588        self.dataChanged.emit(index, index)
1589        return True
1590
1591    def tokens(self):
1592        ans = []
1593        # Tags can be in the news and the tags categories. However, because of
1594        # the desire to use two different icons (tags and news), the nodes are
1595        # not shared, which can lead to the possibility of searching twice for
1596        # the same tag. The tags_seen set helps us prevent that
1597        tags_seen = set()
1598        # Tag nodes are in their own category and possibly in User categories.
1599        # They will be 'checked' in both places, but we want to put the node
1600        # into the search string only once. The nodes_seen set helps us do that
1601        nodes_seen = set()
1602        stars = rating_to_stars(3, True)
1603
1604        node_searches = {TAG_SEARCH_STATES['mark_plus']       : 'true',
1605                         TAG_SEARCH_STATES['mark_plusplus']   : '.true',
1606                         TAG_SEARCH_STATES['mark_minus']      : 'false',
1607                         TAG_SEARCH_STATES['mark_minusminus'] : '.false'}
1608
1609        for node in self.category_nodes:
1610            if node.tag.state:
1611                if node.category_key == "news":
1612                    if node_searches[node.tag.state] == 'true':
1613                        ans.append('tags:"=' + _('News') + '"')
1614                    else:
1615                        ans.append('( not tags:"=' + _('News') + '")')
1616                else:
1617                    ans.append('%s:%s'%(node.category_key, node_searches[node.tag.state]))
1618
1619            key = node.category_key
1620            for tag_item in node.all_children():
1621                if tag_item.type == TagTreeItem.CATEGORY:
1622                    if self.collapse_model == 'first letter' and \
1623                            tag_item.temporary and not key.startswith('@') \
1624                            and tag_item.tag.state:
1625                        k = 'author_sort' if key == 'authors' else key
1626                        letters_seen = {}
1627                        for subnode in tag_item.children:
1628                            if subnode.tag.sort:
1629                                letters_seen[subnode.tag.sort[0]] = True
1630                        if letters_seen:
1631                            charclass = ''.join(letters_seen)
1632                            if k == 'author_sort':
1633                                expr = r'%s:"~(^[%s])|(&\s*[%s])"'%(k, charclass, charclass)
1634                            elif k == 'series':
1635                                expr = r'series_sort:"~^[%s]"'%(charclass)
1636                            else:
1637                                expr = r'%s:"~^[%s]"'%(k, charclass)
1638                        else:
1639                            expr = r'%s:false'%(k)
1640                        if node_searches[tag_item.tag.state] == 'true':
1641                            ans.append(expr)
1642                        else:
1643                            ans.append('(not ' + expr + ')')
1644                    continue
1645                tag = tag_item.tag
1646                if tag.state != TAG_SEARCH_STATES['clear']:
1647                    if tag.state == TAG_SEARCH_STATES['mark_minus'] or \
1648                            tag.state == TAG_SEARCH_STATES['mark_minusminus']:
1649                        prefix = ' not '
1650                    else:
1651                        prefix = ''
1652                    if node.is_gst:
1653                        category = key
1654                    else:
1655                        category = tag.category if key != 'news' else 'tag'
1656                    add_colon = False
1657                    if self.db.field_metadata[tag.category]['is_csp']:
1658                        add_colon = True
1659
1660                    if tag.name and tag.name[0] in stars:  # char is a star or a half. Assume rating
1661                        rnum = len(tag.name)
1662                        if tag.name.endswith(stars[-1]):
1663                            rnum = '%s.5' % (rnum - 1)
1664                        ans.append('%s%s:%s'%(prefix, category, rnum))
1665                    else:
1666                        name = tag.original_name
1667                        use_prefix = tag.state in [TAG_SEARCH_STATES['mark_plusplus'],
1668                                                   TAG_SEARCH_STATES['mark_minusminus']]
1669                        if category == 'tags':
1670                            if name in tags_seen:
1671                                continue
1672                            tags_seen.add(name)
1673                        if tag in nodes_seen:
1674                            continue
1675                        nodes_seen.add(tag)
1676                        n = name.replace(r'"', r'\"')
1677                        if name.startswith('.'):
1678                            n = '.' + n
1679                        ans.append('%s%s:"=%s%s%s"'%(prefix, category,
1680                                                '.' if use_prefix else '', n,
1681                                                ':' if add_colon else ''))
1682        return ans
1683
1684    def find_item_node(self, key, txt, start_path, equals_match=False):
1685        '''
1686        Search for an item (a node) in the tags browser list that matches both
1687        the key (exact case-insensitive match) and txt (not equals_match =>
1688        case-insensitive contains match; equals_match => case_insensitive
1689        equal match). Returns the path to the node. Note that paths are to a
1690        location (second item, fourth item, 25 item), not to a node. If
1691        start_path is None, the search starts with the topmost node. If the tree
1692        is changed subsequent to calling this method, the path can easily refer
1693        to a different node or no node at all.
1694        '''
1695        if not txt:
1696            return None
1697        txt = lower(txt) if not equals_match else txt
1698        self.path_found = None
1699        if start_path is None:
1700            start_path = []
1701        if prefs['use_primary_find_in_search']:
1702            final_strcmp = primary_strcmp
1703            final_contains = primary_contains
1704        else:
1705            final_strcmp = strcmp
1706            final_contains = contains
1707
1708        def process_tag(depth, tag_index, tag_item, start_path):
1709            path = self.path_for_index(tag_index)
1710            if depth < len(start_path) and path[depth] <= start_path[depth]:
1711                return False
1712            tag = tag_item.tag
1713            if tag is None:
1714                return False
1715            name = tag.original_name
1716            if (equals_match and final_strcmp(name, txt) == 0) or \
1717                    (not equals_match and final_contains(txt, name)):
1718                self.path_found = path
1719                return True
1720            for i,c in enumerate(tag_item.children):
1721                if process_tag(depth+1, self.createIndex(i, 0, c), c, start_path):
1722                    return True
1723            return False
1724
1725        def process_level(depth, category_index, start_path):
1726            path = self.path_for_index(category_index)
1727            if depth < len(start_path):
1728                if path[depth] < start_path[depth]:
1729                    return False
1730                if path[depth] > start_path[depth]:
1731                    start_path = path
1732            my_key = self.get_node(category_index).category_key
1733            for j in range(self.rowCount(category_index)):
1734                tag_index = self.index(j, 0, category_index)
1735                tag_item = self.get_node(tag_index)
1736                if tag_item.type == TagTreeItem.CATEGORY:
1737                    if process_level(depth+1, tag_index, start_path):
1738                        return True
1739                elif not key or strcmp(key, my_key) == 0:
1740                    if process_tag(depth+1, tag_index, tag_item, start_path):
1741                        return True
1742            return False
1743
1744        for i in range(self.rowCount(QModelIndex())):
1745            if process_level(0, self.index(i, 0, QModelIndex()), start_path):
1746                break
1747        return self.path_found
1748
1749    def find_category_node(self, key, parent=QModelIndex()):
1750        '''
1751        Search for an category node (a top-level node) in the tags browser list
1752        that matches the key (exact case-insensitive match). Returns the path to
1753        the node. Paths are as in find_item_node.
1754        '''
1755        if not key:
1756            return None
1757
1758        for i in range(self.rowCount(parent)):
1759            idx = self.index(i, 0, parent)
1760            node = self.get_node(idx)
1761            if node.type == TagTreeItem.CATEGORY:
1762                ckey = node.category_key
1763                if strcmp(ckey, key) == 0:
1764                    return self.path_for_index(idx)
1765                if len(node.children):
1766                    v = self.find_category_node(key, idx)
1767                    if v is not None:
1768                        return v
1769        return None
1770
1771    def set_boxed(self, idx):
1772        tag_item = self.get_node(idx)
1773        tag_item.boxed = True
1774        self.dataChanged.emit(idx, idx)
1775
1776    def clear_boxed(self):
1777        '''
1778        Clear all boxes around items.
1779        '''
1780        def process_tag(tag_index, tag_item):
1781            if tag_item.boxed:
1782                tag_item.boxed = False
1783                self.dataChanged.emit(tag_index, tag_index)
1784            for i,c in enumerate(tag_item.children):
1785                process_tag(self.index(i, 0, tag_index), c)
1786
1787        def process_level(category_index):
1788            for j in range(self.rowCount(category_index)):
1789                tag_index = self.index(j, 0, category_index)
1790                tag_item = self.get_node(tag_index)
1791                if tag_item.boxed:
1792                    tag_item.boxed = False
1793                    self.dataChanged.emit(tag_index, tag_index)
1794                if tag_item.type == TagTreeItem.CATEGORY:
1795                    process_level(tag_index)
1796                else:
1797                    process_tag(tag_index, tag_item)
1798
1799        for i in range(self.rowCount(QModelIndex())):
1800            process_level(self.index(i, 0, QModelIndex()))
1801
1802    # }}}
1803