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 os, re, traceback
10from functools import partial
11
12from qt.core import (
13    QStyledItemDelegate, Qt, QTreeView, pyqtSignal, QSize, QIcon, QApplication, QStyle, QAbstractItemView,
14    QMenu, QPoint, QToolTip, QCursor, QDrag, QRect, QModelIndex,
15    QLinearGradient, QPalette, QColor, QPen, QBrush, QFont, QTimer
16)
17
18from calibre import sanitize_file_name
19from calibre.constants import config_dir
20from calibre.ebooks.metadata import rating_to_stars
21from calibre.gui2.complete2 import EditWithComplete
22from calibre.gui2.tag_browser.model import (TagTreeItem, TAG_SEARCH_STATES,
23        TagsModel, DRAG_IMAGE_ROLE, COUNT_ROLE, rename_only_in_vl_question)
24from calibre.gui2.widgets import EnLineEdit
25from calibre.gui2 import (config, gprefs, choose_files, pixmap_to_data,
26                          rating_font, empty_index, question_dialog)
27from calibre.utils.icu import sort_key
28from calibre.utils.serialize import json_loads
29
30
31class TagDelegate(QStyledItemDelegate):  # {{{
32
33    def __init__(self, tags_view):
34        QStyledItemDelegate.__init__(self, tags_view)
35        self.old_look = False
36        self.rating_pat = re.compile(r'[%s]' % rating_to_stars(3, True))
37        self.rating_font = QFont(rating_font())
38        self.completion_data = None
39        self.tags_view = tags_view
40
41    def draw_average_rating(self, item, style, painter, option, widget):
42        rating = item.average_rating
43        if rating is None:
44            return
45        r = style.subElementRect(QStyle.SubElement.SE_ItemViewItemDecoration, option, widget)
46        icon = option.icon
47        painter.save()
48        nr = r.adjusted(0, 0, 0, 0)
49        nr.setBottom(r.bottom()-int(r.height()*(rating/5.0)))
50        painter.setClipRect(nr)
51        bg = option.palette.window()
52        if self.old_look:
53            bg = option.palette.alternateBase() if option.features&option.Alternate else option.palette.base()
54        painter.fillRect(r, bg)
55        style.proxy().drawPrimitive(QStyle.PrimitiveElement.PE_PanelItemViewItem, option, painter, widget)
56        painter.setOpacity(0.3)
57        icon.paint(painter, r, option.decorationAlignment, QIcon.Mode.Normal, QIcon.State.On)
58        painter.restore()
59
60    def draw_icon(self, style, painter, option, widget):
61        r = style.subElementRect(QStyle.SubElement.SE_ItemViewItemDecoration, option, widget)
62        icon = option.icon
63        icon.paint(painter, r, option.decorationAlignment, QIcon.Mode.Normal, QIcon.State.On)
64
65    def paint_text(self, painter, rect, flags, text, hover):
66        set_color = hover and QApplication.instance().is_dark_theme
67        if set_color:
68            painter.save()
69            pen = painter.pen()
70            pen.setColor(QColor(Qt.GlobalColor.black))
71            painter.setPen(pen)
72        painter.drawText(rect, flags, text)
73        if set_color:
74            painter.restore()
75
76    def draw_text(self, style, painter, option, widget, index, item):
77        tr = style.subElementRect(QStyle.SubElement.SE_ItemViewItemText, option, widget)
78        text = index.data(Qt.ItemDataRole.DisplayRole)
79        hover = option.state & QStyle.StateFlag.State_MouseOver
80        is_search = (True if item.type == TagTreeItem.TAG and
81                            item.tag.category == 'search' else False)
82        if not is_search and (hover or gprefs['tag_browser_show_counts']):
83            count = str(index.data(COUNT_ROLE))
84            width = painter.fontMetrics().boundingRect(count).width()
85            r = QRect(tr)
86            r.setRight(r.right() - 1), r.setLeft(r.right() - width - 4)
87            self.paint_text(painter, r, Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextSingleLine, count, hover)
88            tr.setRight(r.left() - 1)
89        else:
90            tr.setRight(tr.right() - 1)
91        is_rating = item.type == TagTreeItem.TAG and not self.rating_pat.sub('', text)
92        if is_rating:
93            painter.setFont(self.rating_font)
94        flags = Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft | Qt.TextFlag.TextSingleLine
95        lr = QRect(tr)
96        lr.setRight(lr.right() * 2)
97        br = painter.boundingRect(lr, flags, text)
98        if br.width() > tr.width():
99            g = QLinearGradient(tr.topLeft(), tr.topRight())
100            c = option.palette.color(QPalette.ColorRole.WindowText)
101            g.setColorAt(0, c), g.setColorAt(0.8, c)
102            c = QColor(c)
103            c.setAlpha(0)
104            g.setColorAt(1, c)
105            pen = QPen()
106            pen.setBrush(QBrush(g))
107            painter.setPen(pen)
108        self.paint_text(painter, tr, flags, text, hover)
109
110    def paint(self, painter, option, index):
111        QStyledItemDelegate.paint(self, painter, option, empty_index)
112        widget = self.parent()
113        style = QApplication.style() if widget is None else widget.style()
114        self.initStyleOption(option, index)
115        item = index.data(Qt.ItemDataRole.UserRole)
116        self.draw_icon(style, painter, option, widget)
117        painter.save()
118        self.draw_text(style, painter, option, widget, index, item)
119        painter.restore()
120        if item.boxed:
121            r = style.subElementRect(QStyle.SubElement.SE_ItemViewItemFocusRect, option,
122                    widget)
123            painter.drawLine(r.bottomLeft(), r.bottomRight())
124        if item.type == TagTreeItem.TAG and item.tag.state == 0 and config['show_avg_rating']:
125            self.draw_average_rating(item, style, painter, option, widget)
126
127    def set_completion_data(self, data):
128        self.completion_data = data
129
130    def createEditor(self, parent, option, index):
131        item = self.tags_view.model().get_node(index)
132        if not item.ignore_vl:
133            if item.use_vl is None:
134                if self.tags_view.model().get_in_vl():
135                    item.use_vl = rename_only_in_vl_question(self.tags_view)
136                else:
137                    item.use_vl = False
138            elif not item.use_vl and self.tags_view.model().get_in_vl():
139                item.use_vl = not question_dialog(self.tags_view,
140                                    _('Rename in Virtual library'), '<p>' +
141                                    _('A Virtual library is active but you are renaming '
142                                      'the item in all books in your library. Is '
143                                      'this really what you want to do?') + '</p>',
144                                    yes_text=_('Yes, apply in entire library'),
145                                    no_text=_('No, apply only in Virtual library'),
146                                    skip_dialog_name='tag_item_rename_in_entire_library')
147        if self.completion_data:
148            editor = EditWithComplete(parent)
149            editor.set_separator(None)
150            editor.update_items_cache(self.completion_data)
151        else:
152            editor = EnLineEdit(parent)
153        return editor
154
155    # }}}
156
157
158class TagsView(QTreeView):  # {{{
159
160    refresh_required        = pyqtSignal()
161    tags_marked             = pyqtSignal(object)
162    edit_user_category      = pyqtSignal(object)
163    delete_user_category    = pyqtSignal(object)
164    del_item_from_user_cat  = pyqtSignal(object, object, object)
165    add_item_to_user_cat    = pyqtSignal(object, object, object)
166    add_subcategory         = pyqtSignal(object)
167    tags_list_edit          = pyqtSignal(object, object, object)
168    saved_search_edit       = pyqtSignal(object)
169    rebuild_saved_searches  = pyqtSignal()
170    author_sort_edit        = pyqtSignal(object, object, object, object, object)
171    tag_item_renamed        = pyqtSignal()
172    search_item_renamed     = pyqtSignal()
173    drag_drop_finished      = pyqtSignal(object)
174    restriction_error       = pyqtSignal(object)
175    tag_item_delete         = pyqtSignal(object, object, object, object, object)
176    tag_identifier_delete   = pyqtSignal(object, object)
177    apply_tag_to_selected   = pyqtSignal(object, object, object)
178    edit_enum_values        = pyqtSignal(object, object, object)
179
180    def __init__(self, parent=None):
181        QTreeView.__init__(self, parent=None)
182        self.possible_drag_start = None
183        self.setProperty('frame_for_focus', True)
184        self.setMouseTracking(True)
185        self.alter_tb = None
186        self.disable_recounting = False
187        self.setUniformRowHeights(True)
188        self.setIconSize(QSize(20, 20))
189        self.setTabKeyNavigation(True)
190        self.setAnimated(True)
191        self.setHeaderHidden(True)
192        self.setItemDelegate(TagDelegate(tags_view=self))
193        self.made_connections = False
194        self.setAcceptDrops(True)
195        self.setDragEnabled(True)
196        self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)
197        self.setDropIndicatorShown(True)
198        self.setAutoExpandDelay(500)
199        self.pane_is_visible = False
200        self.search_icon = QIcon(I('search.png'))
201        self.search_copy_icon = QIcon(I("search_copy_saved.png"))
202        self.user_category_icon = QIcon(I('tb_folder.png'))
203        self.edit_metadata_icon = QIcon(I('edit_input.png'))
204        self.delete_icon = QIcon(I('list_remove.png'))
205        self.rename_icon = QIcon(I('edit-undo.png'))
206        self.plus_icon = QIcon(I('plus.png'))
207        self.minus_icon = QIcon(I('minus.png'))
208
209        self._model = TagsModel(self)
210        self._model.search_item_renamed.connect(self.search_item_renamed)
211        self._model.refresh_required.connect(self.refresh_required,
212                type=Qt.ConnectionType.QueuedConnection)
213        self._model.tag_item_renamed.connect(self.tag_item_renamed)
214        self._model.restriction_error.connect(self.restriction_error)
215        self._model.user_categories_edited.connect(self.user_categories_edited,
216                type=Qt.ConnectionType.QueuedConnection)
217        self._model.drag_drop_finished.connect(self.drag_drop_finished)
218        self._model.convert_requested.connect(self.convert_requested)
219        self.set_look_and_feel(first=True)
220        QApplication.instance().palette_changed.connect(self.set_style_sheet, type=Qt.ConnectionType.QueuedConnection)
221
222    def convert_requested(self, book_ids, to_fmt):
223        from calibre.gui2.ui import get_gui
224        get_gui().iactions['Convert Books'].convert_ebooks_to_format(book_ids, to_fmt)
225
226    def set_style_sheet(self):
227        stylish_tb = '''
228                QTreeView {
229                    background-color: palette(window);
230                    color: palette(window-text);
231                    border: none;
232                }
233        '''
234        self.setStyleSheet('''
235                QTreeView::item {
236                    border: 1px solid transparent;
237                    padding-top:PADex;
238                    padding-bottom:PADex;
239                }
240
241                QTreeView::item:hover {
242                    background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #e7effd, stop: 1 #cbdaf1);
243                    border: 1px solid #bfcde4;
244                    border-radius: 6px;
245                }
246        '''.replace('PAD', str(gprefs['tag_browser_item_padding'])) + (
247            '' if gprefs['tag_browser_old_look'] else stylish_tb))
248
249    def set_look_and_feel(self, first=False):
250        self.set_style_sheet()
251        self.setAlternatingRowColors(gprefs['tag_browser_old_look'])
252        self.itemDelegate().old_look = gprefs['tag_browser_old_look']
253
254        if gprefs['tag_browser_allow_keyboard_focus']:
255            self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
256        else:
257            self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
258        # Ensure the TB doesn't keep the focus it might already have. When this
259        # method is first called during GUI initialization not everything is
260        # set up, in which case don't try to change the focus.
261        # Note: this process has the side effect of moving the focus to the
262        # library view whenever a look & feel preference is changed.
263        if not first:
264            try:
265                from calibre.gui2.ui import get_gui
266                get_gui().shift_esc()
267            except:
268                traceback.print_exc()
269
270    @property
271    def hidden_categories(self):
272        return self._model.hidden_categories
273
274    @property
275    def db(self):
276        return self._model.db
277
278    @property
279    def collapse_model(self):
280        return self._model.collapse_model
281
282    def set_pane_is_visible(self, to_what):
283        pv = self.pane_is_visible
284        self.pane_is_visible = to_what
285        if to_what and not pv:
286            self.recount()
287
288    def get_state(self):
289        state_map = {}
290        expanded_categories = []
291        hide_empty_categories = self.model().prefs['tag_browser_hide_empty_categories']
292        crmap = self._model.category_row_map()
293        for category in self._model.category_nodes:
294            if (category.category_key in self.hidden_categories or (
295                hide_empty_categories and len(category.child_tags()) == 0)):
296                continue
297            row = crmap.get(category.category_key)
298            if row is not None:
299                index = self._model.index(row, 0, QModelIndex())
300                if self.isExpanded(index):
301                    expanded_categories.append(category.category_key)
302            states = [c.tag.state for c in category.child_tags()]
303            names = [(c.tag.name, c.tag.category) for c in category.child_tags()]
304            state_map[category.category_key] = dict(zip(names, states))
305        return expanded_categories, state_map
306
307    def reread_collapse_parameters(self):
308        self._model.reread_collapse_model(self.get_state()[1])
309
310    def set_database(self, db, alter_tb):
311        self._model.set_database(db)
312        self.alter_tb = alter_tb
313        self.pane_is_visible = True  # because TagsModel.set_database did a recount
314        self.setModel(self._model)
315        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
316        pop = self.db.CATEGORY_SORTS.index(config['sort_tags_by'])
317        self.alter_tb.sort_menu.actions()[pop].setChecked(True)
318        try:
319            match_pop = self.db.MATCH_TYPE.index(config['match_tags_type'])
320        except ValueError:
321            match_pop = 0
322        self.alter_tb.match_menu.actions()[match_pop].setChecked(True)
323        if not self.made_connections:
324            self.clicked.connect(self.toggle)
325            self.customContextMenuRequested.connect(self.show_context_menu)
326            self.refresh_required.connect(self.recount, type=Qt.ConnectionType.QueuedConnection)
327            self.alter_tb.sort_menu.triggered.connect(self.sort_changed)
328            self.alter_tb.match_menu.triggered.connect(self.match_changed)
329            self.made_connections = True
330        self.refresh_signal_processed = True
331        db.add_listener(self.database_changed)
332        self.expanded.connect(self.item_expanded)
333        self.collapsed.connect(self.collapse_node_and_children)
334
335    def keyPressEvent(self, event):
336
337        def on_last_visible_item(dex, check_children):
338            model = self._model
339            if model.get_node(dex) == model.root_item:
340                # Got to root. There can't be any more children to show
341                return True
342            if check_children and self.isExpanded(dex):
343                # We are on a node with expanded children so there is a node to go to.
344                # We don't check children if we are moving up the parent hierarchy
345                return False
346            parent = model.parent(dex)
347            if dex.row() < model.rowCount(parent) - 1:
348                # Node has more nodes after it
349                return False
350            # Last node. Check the parent for further to see if there are more nodes
351            return on_last_visible_item(parent, False)
352
353        # I don't see how current_index can ever be not valid, but ...
354        if self.currentIndex().isValid():
355            key = event.key()
356            if gprefs['tag_browser_allow_keyboard_focus']:
357                if key == Qt.Key.Key_Return and self.state() != QAbstractItemView.State.EditingState:
358                    self.toggle_current_index()
359                    return
360                # Check if we are moving the focus and we are at the beginning or the
361                # end of the list. The goal is to prevent moving focus away from the
362                # tag browser.
363                if key == Qt.Key.Key_Tab:
364                    if not on_last_visible_item(self.currentIndex(), True):
365                        QTreeView.keyPressEvent(self, event)
366                    return
367                if key == Qt.Key.Key_Backtab:
368                    if self.model().get_node(self.currentIndex()) != self._model.root_item.children[0]:
369                        QTreeView.keyPressEvent(self, event)
370                    return
371            # If this is an edit request, mark the node to request whether to use VLs
372            # As far as I can tell, F2 is used across all platforms
373            if key == Qt.Key.Key_F2:
374                node = self.model().get_node(self.currentIndex())
375                if node.type == TagTreeItem.TAG:
376                    # Saved search nodes don't use the VL test/dialog
377                    node.use_vl = None
378                    node.ignore_vl = node.tag.category == 'search'
379                else:
380                    # Don't open the editor for non-editable items
381                    if not node.category_key.startswith('@') or node.is_gst:
382                        return
383                    # Category nodes don't use the VL test/dialog
384                    node.use_vl = False
385                    node.ignore_vl = True
386        QTreeView.keyPressEvent(self, event)
387
388    def database_changed(self, event, ids):
389        if self.refresh_signal_processed:
390            self.refresh_signal_processed = False
391            self.refresh_required.emit()
392
393    def user_categories_edited(self, user_cats, nkey):
394        state_map = self.get_state()[1]
395        self.db.new_api.set_pref('user_categories', user_cats)
396        self._model.rebuild_node_tree(state_map=state_map)
397        p = self._model.find_category_node('@'+nkey)
398        self.show_item_at_path(p)
399
400    @property
401    def match_all(self):
402        return (self.alter_tb and self.alter_tb.match_menu.actions()[1].isChecked())
403
404    def sort_changed(self, action):
405        for i, ac in enumerate(self.alter_tb.sort_menu.actions()):
406            if ac is action:
407                config.set('sort_tags_by', self.db.CATEGORY_SORTS[i])
408                self.recount()
409                break
410
411    def match_changed(self, action):
412        try:
413            for i, ac in enumerate(self.alter_tb.match_menu.actions()):
414                if ac is action:
415                    config.set('match_tags_type', self.db.MATCH_TYPE[i])
416        except:
417            pass
418
419    def mousePressEvent(self, event):
420        if event.buttons() & Qt.MouseButton.LeftButton:
421            # Only remember a possible drag start if the item is drag enabled
422            dex = self.indexAt(event.pos())
423            if self._model.flags(dex) & Qt.ItemFlag.ItemIsDragEnabled:
424                self.possible_drag_start = event.pos()
425            else:
426                self.possible_drag_start = None
427        return QTreeView.mousePressEvent(self, event)
428
429    def mouseMoveEvent(self, event):
430        dex = self.indexAt(event.pos())
431        if dex.isValid():
432            self.setCursor(Qt.CursorShape.PointingHandCursor)
433        else:
434            self.unsetCursor()
435        if not event.buttons() & Qt.MouseButton.LeftButton:
436            return
437        if not dex.isValid():
438            QTreeView.mouseMoveEvent(self, event)
439            return
440        # don't start drag/drop until the mouse has moved a bit.
441        if (self.possible_drag_start is None or
442            (event.pos() - self.possible_drag_start).manhattanLength() <
443                                    QApplication.startDragDistance()):
444            QTreeView.mouseMoveEvent(self, event)
445            return
446
447        if not self._model.flags(dex) & Qt.ItemFlag.ItemIsDragEnabled:
448            QTreeView.mouseMoveEvent(self, event)
449            return
450        md = self._model.mimeData([dex])
451        pixmap = dex.data(DRAG_IMAGE_ROLE).pixmap(self.iconSize())
452        drag = QDrag(self)
453        drag.setPixmap(pixmap)
454        drag.setMimeData(md)
455        if (self._model.is_in_user_category(dex) or
456                    self._model.is_index_on_a_hierarchical_category(dex)):
457            '''
458            Things break if we specify MoveAction as the default, which is
459            what we want for drag on hierarchical categories. Dragging user
460            categories stops working. Don't know why. To avoid the problem
461            we fix the action in dragMoveEvent.
462            '''
463            drag.exec(Qt.DropAction.CopyAction|Qt.DropAction.MoveAction, Qt.DropAction.CopyAction)
464        else:
465            drag.exec(Qt.DropAction.CopyAction)
466
467    def mouseDoubleClickEvent(self, event):
468        # swallow these to avoid toggling and editing at the same time
469        pass
470
471    @property
472    def search_string(self):
473        tokens = self._model.tokens()
474        joiner = ' and ' if self.match_all else ' or '
475        return joiner.join(tokens)
476
477    def toggle_current_index(self):
478        ci = self.currentIndex()
479        if ci.isValid():
480            self.toggle(ci)
481
482    def toggle(self, index):
483        self._toggle(index, None)
484
485    def _toggle(self, index, set_to):
486        '''
487        set_to: if None, advance the state. Otherwise must be one of the values
488        in TAG_SEARCH_STATES
489        '''
490        exclusive = QApplication.keyboardModifiers() not in (Qt.KeyboardModifier.ControlModifier, Qt.KeyboardModifier.ShiftModifier)
491        if self._model.toggle(index, exclusive, set_to=set_to):
492            # Reset the focus back to TB if it has it before the toggle
493            # Must ask this question before starting the search because
494            # it changes the focus
495            has_focus = self.hasFocus()
496            self.tags_marked.emit(self.search_string)
497            if has_focus and gprefs['tag_browser_allow_keyboard_focus']:
498                # Reset the focus to the TB. Use the singleshot in case
499                # some of searching is done using queued signals.
500                QTimer.singleShot(0, lambda: self.setFocus())
501
502    def conditional_clear(self, search_string):
503        if search_string != self.search_string:
504            self.clear()
505
506    def context_menu_handler(self, action=None, category=None,
507                             key=None, index=None, search_state=None,
508                             is_first_letter=False, ignore_vl=False):
509        if not action:
510            return
511        try:
512            if action == 'set_icon':
513                try:
514                    path = choose_files(self, 'choose_category_icon',
515                                _('Change icon for: %s')%key, filters=[
516                                ('Images', ['png', 'gif', 'jpg', 'jpeg'])],
517                            all_files=False, select_only_single_file=True)
518                    if path:
519                        path = path[0]
520                        p = QIcon(path).pixmap(QSize(128, 128))
521                        d = os.path.join(config_dir, 'tb_icons')
522                        if not os.path.exists(d):
523                            os.makedirs(d)
524                        with open(os.path.join(d, 'icon_' + sanitize_file_name(key)+'.png'), 'wb') as f:
525                            f.write(pixmap_to_data(p, format='PNG'))
526                            path = os.path.basename(f.name)
527                        self._model.set_custom_category_icon(key, str(path))
528                        self.recount()
529                except:
530                    traceback.print_exc()
531                return
532            if action == 'clear_icon':
533                self._model.set_custom_category_icon(key, None)
534                self.recount()
535                return
536
537            def set_completion_data(category):
538                try:
539                    completion_data = self.db.new_api.all_field_names(category)
540                except:
541                    completion_data = None
542                self.itemDelegate().set_completion_data(completion_data)
543
544            if action == 'edit_item_no_vl':
545                item = self.model().get_node(index)
546                item.use_vl = False
547                item.ignore_vl = ignore_vl
548                set_completion_data(category)
549                self.edit(index)
550                return
551            if action == 'edit_item_in_vl':
552                item = self.model().get_node(index)
553                item.use_vl = True
554                item.ignore_vl = ignore_vl
555                set_completion_data(category)
556                self.edit(index)
557                return
558            if action == 'delete_item_in_vl':
559                tag = index.tag
560                id_ = tag.id if tag.is_editable else None
561                children = index.child_tags()
562                self.tag_item_delete.emit(key, id_, tag.original_name,
563                                          self.model().get_book_ids_to_use(),
564                                          children)
565                return
566            if action == 'delete_item_no_vl':
567                tag = index.tag
568                id_ = tag.id if tag.is_editable else None
569                children = index.child_tags()
570                self.tag_item_delete.emit(key, id_, tag.original_name,
571                                          None, children)
572                return
573            if action == 'delete_identifier':
574                self.tag_identifier_delete.emit(index.tag.name, False)
575                return
576            if action == 'delete_identifier_in_vl':
577                self.tag_identifier_delete.emit(index.tag.name, True)
578                return
579            if action == 'open_editor':
580                self.tags_list_edit.emit(category, key, is_first_letter)
581                return
582            if action == 'manage_categories':
583                self.edit_user_category.emit(category)
584                return
585            if action == 'search':
586                self._toggle(index, set_to=search_state)
587                return
588            if action == "raw_search":
589                from calibre.gui2.ui import get_gui
590                get_gui().get_saved_search_text(search_name='search:' + key)
591                return
592            if action == 'add_to_category':
593                tag = index.tag
594                if len(index.children) > 0:
595                    for c in index.all_children():
596                        self.add_item_to_user_cat.emit(category, c.tag.original_name,
597                                               c.tag.category)
598                self.add_item_to_user_cat.emit(category, tag.original_name,
599                                               tag.category)
600                return
601            if action == 'add_subcategory':
602                self.add_subcategory.emit(key)
603                return
604            if action == 'search_category':
605                self._toggle(index, set_to=search_state)
606                return
607            if action == 'delete_user_category':
608                self.delete_user_category.emit(key)
609                return
610            if action == 'delete_search':
611                if not question_dialog(
612                    self,
613                    title=_('Delete Saved search'),
614                    msg='<p>'+ _('Delete the saved search: {}?').format(key),
615                    skip_dialog_name='tb_delete_saved_search',
616                    skip_dialog_msg=_('Show this confirmation again')
617                ):
618                    return
619                self.model().db.saved_search_delete(key)
620                self.rebuild_saved_searches.emit()
621                return
622            if action == 'delete_item_from_user_category':
623                tag = index.tag
624                if len(index.children) > 0:
625                    for c in index.children:
626                        self.del_item_from_user_cat.emit(key, c.tag.original_name,
627                                               c.tag.category)
628                self.del_item_from_user_cat.emit(key, tag.original_name, tag.category)
629                return
630            if action == 'manage_searches':
631                self.saved_search_edit.emit(category)
632                return
633            if action == 'edit_authors':
634                self.author_sort_edit.emit(self, index, False, False, is_first_letter)
635                return
636            if action == 'edit_author_sort':
637                self.author_sort_edit.emit(self, index, True, False, is_first_letter)
638                return
639            if action == 'edit_author_link':
640                self.author_sort_edit.emit(self, index, False, True, False)
641                return
642
643            reset_filter_categories = True
644            if action == 'hide':
645                self.hidden_categories.add(category)
646            elif action == 'show':
647                self.hidden_categories.discard(category)
648            elif action == 'categorization':
649                changed = self.collapse_model != category
650                self._model.collapse_model = category
651                if changed:
652                    reset_filter_categories = False
653                    gprefs['tags_browser_partition_method'] = category
654            elif action == 'defaults':
655                self.hidden_categories.clear()
656            elif action == 'add_tag':
657                item = self.model().get_node(index)
658                if item is not None:
659                    self.apply_to_selected_books(item)
660                return
661            elif action == 'remove_tag':
662                item = self.model().get_node(index)
663                if item is not None:
664                    self.apply_to_selected_books(item, True)
665                return
666            elif action == 'edit_enum':
667                self.edit_enum_values.emit(self, self.db, key)
668                return
669            self.db.new_api.set_pref('tag_browser_hidden_categories', list(self.hidden_categories))
670            if reset_filter_categories:
671                self._model.set_categories_filter(None)
672            self._model.rebuild_node_tree()
673        except Exception:
674            traceback.print_exc()
675            return
676
677    def apply_to_selected_books(self, item, remove=False):
678        if item.type != item.TAG:
679            return
680        tag = item.tag
681        if not tag.category or not tag.original_name:
682            return
683        self.apply_tag_to_selected.emit(tag.category, tag.original_name, remove)
684
685    def show_context_menu(self, point):
686        def display_name(tag):
687            ans = tag.name
688            if tag.category == 'search':
689                n = tag.name
690                if len(n) > 45:
691                    n = n[:45] + '...'
692                ans = n
693            elif tag.is_hierarchical and not tag.is_editable:
694                ans = tag.original_name
695            if ans:
696                ans = ans.replace('&', '&&')
697            return ans
698
699        index = self.indexAt(point)
700        self.context_menu = QMenu(self)
701        added_show_hidden_categories = False
702
703        def add_show_hidden_categories():
704            nonlocal added_show_hidden_categories
705            if self.hidden_categories and not added_show_hidden_categories:
706                added_show_hidden_categories = True
707                m = self.context_menu.addMenu(_('Show category'))
708                m.setIcon(QIcon(I('plus.png')))
709                for col in sorted(self.hidden_categories,
710                        key=lambda x: sort_key(self.db.field_metadata[x]['name'])):
711                    ac = m.addAction(self.db.field_metadata[col]['name'],
712                        partial(self.context_menu_handler, action='show', category=col))
713                    ic = self.model().category_custom_icons.get(col)
714                    if ic:
715                        ac.setIcon(QIcon(ic))
716                m.addSeparator()
717                m.addAction(_('All categories'),
718                        partial(self.context_menu_handler, action='defaults')).setIcon(QIcon(I('plusplus.png')))
719
720        search_submenu = None
721        if index.isValid():
722            item = index.data(Qt.ItemDataRole.UserRole)
723            tag = None
724            tag_item = item
725
726            if item.type == TagTreeItem.TAG:
727                tag = item.tag
728                while item.type != TagTreeItem.CATEGORY:
729                    item = item.parent
730
731            if item.type == TagTreeItem.CATEGORY:
732                if not item.category_key.startswith('@'):
733                    while item.parent != self._model.root_item:
734                        item = item.parent
735                category = str(item.name or '')
736                key = item.category_key
737                # Verify that we are working with a field that we know something about
738                if key not in self.db.field_metadata:
739                    return True
740                fm = self.db.field_metadata[key]
741
742                # Did the user click on a leaf node?
743                if tag:
744                    # If the user right-clicked on an editable item, then offer
745                    # the possibility of renaming that item.
746                    if (fm['datatype'] != 'composite' and
747                            (tag.is_editable or tag.is_hierarchical) and
748                            key != 'search'):
749                        # Add the 'rename' items to both interior and leaf nodes
750                        if fm['datatype'] != 'enumeration':
751                            if self.model().get_in_vl():
752                                self.context_menu.addAction(self.rename_icon,
753                                        _('Rename %s in Virtual library')%display_name(tag),
754                                        partial(self.context_menu_handler, action='edit_item_in_vl',
755                                                index=index, category=key))
756                            self.context_menu.addAction(self.rename_icon,
757                                        _('Rename %s')%display_name(tag),
758                                        partial(self.context_menu_handler, action='edit_item_no_vl',
759                                                index=index, category=key))
760                        if key in ('tags', 'series', 'publisher') or \
761                                self._model.db.field_metadata.is_custom_field(key):
762                            if self.model().get_in_vl():
763                                self.context_menu.addAction(self.delete_icon,
764                                                    _('Delete %s in Virtual library')%display_name(tag),
765                                partial(self.context_menu_handler, action='delete_item_in_vl',
766                                    key=key, index=tag_item))
767
768                            self.context_menu.addAction(self.delete_icon,
769                                                    _('Delete %s')%display_name(tag),
770                                partial(self.context_menu_handler, action='delete_item_no_vl',
771                                    key=key, index=tag_item))
772                    if tag.is_editable:
773                        if key == 'authors':
774                            self.context_menu.addAction(_('Edit sort for %s')%display_name(tag),
775                                    partial(self.context_menu_handler,
776                                            action='edit_author_sort', index=tag.id)).setIcon(QIcon(I('auto_author_sort.png')))
777                            self.context_menu.addAction(_('Edit link for %s')%display_name(tag),
778                                    partial(self.context_menu_handler,
779                                            action='edit_author_link', index=tag.id)).setIcon(QIcon(I('insert-link.png')))
780
781                        # is_editable is also overloaded to mean 'can be added
782                        # to a User category'
783                        m = QMenu(_('Add %s to User category')%display_name(tag), self.context_menu)
784                        m.setIcon(self.user_category_icon)
785                        added = [False]
786
787                        def add_node_tree(tree_dict, m, path):
788                            p = path[:]
789                            for k in sorted(tree_dict.keys(), key=sort_key):
790                                p.append(k)
791                                n = k[1:] if k.startswith('@') else k
792                                m.addAction(self.user_category_icon, n,
793                                    partial(self.context_menu_handler,
794                                            'add_to_category',
795                                            category='.'.join(p), index=tag_item))
796                                added[0] = True
797                                if len(tree_dict[k]):
798                                    tm = m.addMenu(self.user_category_icon,
799                                                   _('Children of %s')%n)
800                                    add_node_tree(tree_dict[k], tm, p)
801                                p.pop()
802                        add_node_tree(self.model().user_category_node_tree, m, [])
803                        if added[0]:
804                            self.context_menu.addMenu(m)
805
806                        # is_editable also means the tag can be applied/removed
807                        # from selected books
808                        if fm['datatype'] != 'rating':
809                            m = self.context_menu.addMenu(self.edit_metadata_icon,
810                                            _('Add/remove %s to selected books')%display_name(tag))
811                            m.addAction(self.plus_icon,
812                                _('Add %s to selected books') % display_name(tag),
813                                partial(self.context_menu_handler, action='add_tag', index=index))
814                            m.addAction(self.minus_icon,
815                                _('Remove %s from selected books') % display_name(tag),
816                                partial(self.context_menu_handler, action='remove_tag', index=index))
817
818                    elif key == 'search' and tag.is_searchable:
819                        self.context_menu.addAction(self.rename_icon,
820                                                    _('Rename %s')%display_name(tag),
821                            partial(self.context_menu_handler, action='edit_item_no_vl',
822                                    index=index, ignore_vl=True))
823                        self.context_menu.addAction(self.delete_icon,
824                                _('Delete Saved search %s')%display_name(tag),
825                                partial(self.context_menu_handler,
826                                        action='delete_search', key=tag.original_name))
827                    elif key == 'identifiers':
828                        if self.model().get_in_vl():
829                            self.context_menu.addAction(self.delete_icon,
830                                    _('Delete %s in Virtual Library')%display_name(tag),
831                                    partial(self.context_menu_handler,
832                                            action='delete_identifier_in_vl',
833                                            key=key, index=tag_item))
834                        else:
835                            self.context_menu.addAction(self.delete_icon,
836                                    _('Delete %s')%display_name(tag),
837                                    partial(self.context_menu_handler,
838                                            action='delete_identifier',
839                                            key=key, index=tag_item))
840
841                    if key.startswith('@') and not item.is_gst:
842                        self.context_menu.addAction(self.user_category_icon,
843                            _('Remove %(item)s from category %(cat)s')%
844                            dict(item=display_name(tag), cat=item.py_name),
845                            partial(self.context_menu_handler,
846                                    action='delete_item_from_user_category',
847                                    key=key, index=tag_item))
848                    if tag.is_searchable:
849                        # Add the search for value items. All leaf nodes are searchable
850                        self.context_menu.addSeparator()
851                        search_submenu = self.context_menu.addMenu(_('Search for'))
852                        search_submenu.setIcon(QIcon(I('search.png')))
853                        search_submenu.addAction(self.search_icon,
854                                '%s'%display_name(tag),
855                                partial(self.context_menu_handler, action='search',
856                                        search_state=TAG_SEARCH_STATES['mark_plus'],
857                                        index=index))
858                        add_child_search = (tag.is_hierarchical == '5state' and
859                                            len(tag_item.children))
860                        if add_child_search:
861                            search_submenu.addAction(self.search_icon,
862                                    _('%s and its children')%display_name(tag),
863                                    partial(self.context_menu_handler, action='search',
864                                            search_state=TAG_SEARCH_STATES['mark_plusplus'],
865                                            index=index))
866                        search_submenu.addAction(self.search_icon,
867                                _('Everything but %s')%display_name(tag),
868                                partial(self.context_menu_handler, action='search',
869                                        search_state=TAG_SEARCH_STATES['mark_minus'],
870                                        index=index))
871                        if add_child_search:
872                            search_submenu.addAction(self.search_icon,
873                                    _('Everything but %s and its children')%display_name(tag),
874                                    partial(self.context_menu_handler, action='search',
875                                            search_state=TAG_SEARCH_STATES['mark_minusminus'],
876                                            index=index))
877                        if key == 'search':
878                            search_submenu.addAction(self.search_copy_icon,
879                                     _('The saved search expression'),
880                                     partial(self.context_menu_handler, action='raw_search',
881                                             key=tag.name))
882                    self.context_menu.addSeparator()
883                elif key.startswith('@') and not item.is_gst:
884                    if item.can_be_edited:
885                        self.context_menu.addAction(self.rename_icon,
886                            _('Rename %s')%item.py_name,
887                            partial(self.context_menu_handler, action='edit_item_no_vl',
888                                    index=index, ignore_vl=True))
889                    self.context_menu.addAction(self.user_category_icon,
890                            _('Add sub-category to %s')%item.py_name,
891                            partial(self.context_menu_handler,
892                                    action='add_subcategory', key=key))
893                    self.context_menu.addAction(self.delete_icon,
894                            _('Delete User category %s')%item.py_name,
895                            partial(self.context_menu_handler,
896                                    action='delete_user_category', key=key))
897                    self.context_menu.addSeparator()
898                # Add searches for temporary first letter nodes
899                if self._model.collapse_model == 'first letter' and \
900                        tag_item.temporary and not key.startswith('@'):
901                    self.context_menu.addSeparator()
902                    search_submenu = self.context_menu.addMenu(_('Search for'))
903                    search_submenu.setIcon(QIcon(I('search.png')))
904                    search_submenu.addAction(self.search_icon,
905                            '%s'%display_name(tag_item.tag),
906                            partial(self.context_menu_handler, action='search',
907                                    search_state=TAG_SEARCH_STATES['mark_plus'],
908                                    index=index))
909                    search_submenu.addAction(self.search_icon,
910                            _('Everything but %s')%display_name(tag_item.tag),
911                            partial(self.context_menu_handler, action='search',
912                                    search_state=TAG_SEARCH_STATES['mark_minus'],
913                                    index=index))
914                # search by category. Some categories are not searchable, such
915                # as search and news
916                if item.tag.is_searchable:
917                    if search_submenu is None:
918                        search_submenu = self.context_menu.addMenu(_('Search for'))
919                        search_submenu.setIcon(QIcon(I('search.png')))
920                        self.context_menu.addSeparator()
921                    else:
922                        search_submenu.addSeparator()
923                    search_submenu.addAction(self.search_icon,
924                            _('Books in category %s')%category,
925                            partial(self.context_menu_handler,
926                                    action='search_category',
927                                    index=self._model.createIndex(item.row(), 0, item),
928                                    search_state=TAG_SEARCH_STATES['mark_plus']))
929                    search_submenu.addAction(self.search_icon,
930                            _('Books not in category %s')%category,
931                            partial(self.context_menu_handler,
932                                    action='search_category',
933                                    index=self._model.createIndex(item.row(), 0, item),
934                                    search_state=TAG_SEARCH_STATES['mark_minus']))
935
936                # Offer specific editors for tags/series/publishers/saved searches
937                self.context_menu.addSeparator()
938                if key in ['tags', 'publisher', 'series'] or (
939                        fm['is_custom'] and fm['datatype'] != 'composite'):
940                    if tag_item.type == TagTreeItem.CATEGORY and tag_item.temporary:
941                        ac = self.context_menu.addAction(_('Manage %s')%category,
942                            partial(self.context_menu_handler, action='open_editor',
943                                    category=tag_item.name,
944                                    key=key, is_first_letter=True))
945                    else:
946                        ac = self.context_menu.addAction(_('Manage %s')%category,
947                            partial(self.context_menu_handler, action='open_editor',
948                                    category=tag.original_name if tag else None,
949                                    key=key))
950                    ic = self.model().category_custom_icons.get(key)
951                    if ic:
952                        ac.setIcon(QIcon(ic))
953                    if fm['datatype'] == 'enumeration':
954                        self.context_menu.addAction(_('Edit permissible values for %s')%category,
955                            partial(self.context_menu_handler, action='edit_enum',
956                                    key=key))
957                elif key == 'authors':
958                    if tag_item.type == TagTreeItem.CATEGORY:
959                        if tag_item.temporary:
960                            ac = self.context_menu.addAction(_('Manage %s')%category,
961                                partial(self.context_menu_handler, action='edit_authors',
962                                        index=tag_item.name, is_first_letter=True))
963                        else:
964                            ac = self.context_menu.addAction(_('Manage %s')%category,
965                                partial(self.context_menu_handler, action='edit_authors'))
966                    else:
967                        ac = self.context_menu.addAction(_('Manage %s')%category,
968                            partial(self.context_menu_handler, action='edit_authors',
969                                    index=tag.id))
970                    ic = self.model().category_custom_icons.get(key)
971                    if ic:
972                        ac.setIcon(QIcon(ic))
973                elif key == 'search':
974                    self.context_menu.addAction(_('Manage Saved searches'),
975                        partial(self.context_menu_handler, action='manage_searches',
976                                category=tag.name if tag else None))
977
978                # Hide/Show/Restore categories
979                self.context_menu.addSeparator()
980                self.context_menu.addAction(_('Hide category %s') % category,
981                    partial(self.context_menu_handler, action='hide',
982                            category=key)).setIcon(QIcon(I('minus.png')))
983                add_show_hidden_categories()
984
985                if tag is None:
986                    self.context_menu.addSeparator()
987                    self.context_menu.addAction(_('Change category icon'),
988                            partial(self.context_menu_handler, action='set_icon', key=key)).setIcon(QIcon(I('icon_choose.png')))
989                    self.context_menu.addAction(_('Restore default icon'),
990                            partial(self.context_menu_handler, action='clear_icon', key=key)).setIcon(QIcon(I('edit-clear.png')))
991
992                # Always show the User categories editor
993                self.context_menu.addSeparator()
994                if key.startswith('@') and \
995                        key[1:] in self.db.new_api.pref('user_categories', {}).keys():
996                    self.context_menu.addAction(self.user_category_icon,
997                            _('Manage User categories'),
998                            partial(self.context_menu_handler, action='manage_categories',
999                                    category=key[1:]))
1000                else:
1001                    self.context_menu.addAction(self.user_category_icon,
1002                            _('Manage User categories'),
1003                            partial(self.context_menu_handler, action='manage_categories',
1004                                    category=None))
1005        if self.hidden_categories:
1006            if not self.context_menu.isEmpty():
1007                self.context_menu.addSeparator()
1008            add_show_hidden_categories()
1009
1010        m = self.context_menu.addMenu(_('Change sub-categorization scheme'))
1011        m.setIcon(QIcon(I('config.png')))
1012        da = m.addAction(_('Disable'),
1013            partial(self.context_menu_handler, action='categorization', category='disable'))
1014        fla = m.addAction(_('By first letter'),
1015            partial(self.context_menu_handler, action='categorization', category='first letter'))
1016        pa = m.addAction(_('Partition'),
1017            partial(self.context_menu_handler, action='categorization', category='partition'))
1018        if self.collapse_model == 'disable':
1019            da.setCheckable(True)
1020            da.setChecked(True)
1021        elif self.collapse_model == 'first letter':
1022            fla.setCheckable(True)
1023            fla.setChecked(True)
1024        else:
1025            pa.setCheckable(True)
1026            pa.setChecked(True)
1027
1028        if config['sort_tags_by'] != "name":
1029            fla.setEnabled(False)
1030            m.hovered.connect(self.collapse_menu_hovered)
1031            fla.setToolTip(_('First letter is usable only when sorting by name'))
1032            # Apparently one cannot set a tooltip to empty, so use a star and
1033            # deal with it in the hover method
1034            da.setToolTip('*')
1035            pa.setToolTip('*')
1036
1037        # Add expand menu items
1038        self.context_menu.addSeparator()
1039        m = self.context_menu.addMenu(_('Expand or collapse'))
1040        try:
1041            node_name = self._model.get_node(index).tag.name
1042        except AttributeError:
1043            pass
1044        else:
1045            if self.has_children(index) and not self.isExpanded(index):
1046                m.addAction(self.plus_icon,
1047                            _('Expand {0}').format(node_name), partial(self.expand, index))
1048            if self.has_unexpanded_children(index):
1049                m.addAction(self.plus_icon,
1050                            _('Expand {0} and its children').format(node_name),
1051                                            partial(self.expand_node_and_children, index))
1052
1053        # Add menu items to collapse parent nodes
1054        idx = index
1055        paths = []
1056        while True:
1057            # First walk up the node tree getting the displayed names of
1058            # expanded parent nodes
1059            node = self._model.get_node(idx)
1060            if node.type == TagTreeItem.ROOT:
1061                break
1062            if self.has_children(idx) and self.isExpanded(idx):
1063                # leaf nodes don't have children so can't be expanded.
1064                # Also the leaf node might be collapsed
1065                paths.append((node.tag.name, idx))
1066            idx = self._model.parent(idx)
1067        for p in paths:
1068            # Now add the menu items
1069            m.addAction(self.minus_icon,
1070                        _("Collapse {0}").format(p[0]), partial(self.collapse_node, p[1]))
1071        m.addAction(self.minus_icon, _('Collapse all'), self.collapseAll)
1072
1073        # Ask plugins if they have any actions to add to the context menu
1074        from calibre.gui2.ui import get_gui
1075        first = True
1076        for ac in get_gui().iactions.values():
1077            try:
1078                for context_action in ac.tag_browser_context_action(index):
1079                    if first:
1080                        self.context_menu.addSeparator()
1081                        first = False
1082                    self.context_menu.addAction(context_action)
1083            except Exception:
1084                import traceback
1085                traceback.print_exc()
1086
1087        if not self.context_menu.isEmpty():
1088            self.context_menu.popup(self.mapToGlobal(point))
1089        return True
1090
1091    def has_children(self, idx):
1092        return self.model().rowCount(idx) > 0
1093
1094    def collapse_node_and_children(self, idx):
1095        self.collapse(idx)
1096        for r in range(self.model().rowCount(idx)):
1097            self.collapse_node_and_children(idx.child(r, 0))
1098
1099    def collapse_node(self, idx):
1100        if not idx.isValid():
1101            return
1102        self.collapse_node_and_children(idx)
1103        self.setCurrentIndex(idx)
1104        self.scrollTo(idx)
1105
1106    def expand_node_and_children(self, index):
1107        if not index.isValid():
1108            return
1109        self.expand(index)
1110        for r in range(self.model().rowCount(index)):
1111            self.expand_node_and_children(index.child(r, 0))
1112
1113    def has_unexpanded_children(self, index):
1114        if not index.isValid():
1115            return False
1116        for r in range(self._model.rowCount(index)):
1117            dex = index.child(r, 0)
1118            if self._model.rowCount(dex) > 0:
1119                if not self.isExpanded(dex):
1120                    return True
1121                return self.has_unexpanded_children(dex)
1122        return False
1123
1124    def collapse_menu_hovered(self, action):
1125        tip = action.toolTip()
1126        if tip == '*':
1127            tip = ''
1128        QToolTip.showText(QCursor.pos(), tip)
1129
1130    def dragMoveEvent(self, event):
1131        QTreeView.dragMoveEvent(self, event)
1132        self.setDropIndicatorShown(False)
1133        index = self.indexAt(event.pos())
1134        if not index.isValid():
1135            return
1136        src_is_tb = event.mimeData().hasFormat('application/calibre+from_tag_browser')
1137        item = index.data(Qt.ItemDataRole.UserRole)
1138        if item.type == TagTreeItem.ROOT:
1139            return
1140
1141        if src_is_tb:
1142            src_json = json_loads(bytes(event.mimeData().data('application/calibre+from_tag_browser')))
1143            if len(src_json) > 1:
1144                # Should never have multiple mimedata from the tag browser
1145                return
1146        if src_is_tb:
1147            src_md = src_json[0]
1148            src_item = self._model.get_node(self._model.index_for_path(src_md[5]))
1149            # Check if this is an intra-hierarchical-category drag/drop
1150            if (src_item.type == TagTreeItem.TAG and
1151                    src_item.tag.category == item.tag.category and
1152                    not item.temporary and
1153                    self._model.is_key_a_hierarchical_category(src_item.tag.category)):
1154                event.setDropAction(Qt.DropAction.MoveAction)
1155                self.setDropIndicatorShown(True)
1156                return
1157        # We aren't dropping an item on its own category. Check if the dest is
1158        # not a user category and can be dropped on. This covers drops from the
1159        # booklist. It is OK to drop onto virtual nodes
1160        if item.type == TagTreeItem.TAG and self._model.flags(index) & Qt.ItemFlag.ItemIsDropEnabled:
1161            event.setDropAction(Qt.DropAction.CopyAction)
1162            self.setDropIndicatorShown(not src_is_tb)
1163            return
1164        # Now see if we are on a user category and the source can be dropped there
1165        if item.type == TagTreeItem.CATEGORY and not item.is_gst:
1166            fm_dest = self.db.metadata_for_field(item.category_key)
1167            if fm_dest['kind'] == 'user':
1168                if src_is_tb:
1169                    # src_md and src_item are initialized above
1170                    if event.dropAction() == Qt.DropAction.MoveAction:
1171                        # can move only from user categories
1172                        if (src_md[0] == TagTreeItem.TAG and
1173                                 (not src_md[1].startswith('@') or src_md[2])):
1174                            return
1175                    # can't copy virtual nodes into a user category
1176                    if src_item.tag.is_editable:
1177                        self.setDropIndicatorShown(True)
1178                    return
1179                md = event.mimeData()
1180                # Check for drag to user category from the book list. Can handle
1181                # only non-multiple columns, except for some unknown reason authors
1182                if hasattr(md, 'column_name'):
1183                    fm_src = self.db.metadata_for_field(md.column_name)
1184                    if md.column_name in ['authors', 'publisher', 'series'] or \
1185                            (fm_src['is_custom'] and
1186                             ((fm_src['datatype'] in ['series', 'text', 'enumeration'] and
1187                                 not fm_src['is_multiple']) or
1188                              (fm_src['datatype'] == 'composite' and
1189                                  fm_src['display'].get('make_category', False)))):
1190                        self.setDropIndicatorShown(True)
1191
1192    def clear(self):
1193        if self.model():
1194            self.model().clear_state()
1195
1196    def is_visible(self, idx):
1197        item = idx.data(Qt.ItemDataRole.UserRole)
1198        if getattr(item, 'type', None) == TagTreeItem.TAG:
1199            idx = idx.parent()
1200        return self.isExpanded(idx)
1201
1202    def recount_with_position_based_index(self):
1203        self._model.use_position_based_index_on_next_recount = True
1204        self.recount()
1205
1206    def recount(self, *args):
1207        '''
1208        Rebuild the category tree, expand any categories that were expanded,
1209        reset the search states, and reselect the current node.
1210        '''
1211        if self.disable_recounting or not self.pane_is_visible:
1212            return
1213        self.refresh_signal_processed = True
1214        ci = self.currentIndex()
1215        if not ci.isValid():
1216            ci = self.indexAt(QPoint(10, 10))
1217        use_pos = self._model.use_position_based_index_on_next_recount
1218        self._model.use_position_based_index_on_next_recount = False
1219        if use_pos:
1220            path = self._model.path_for_index(ci) if self.is_visible(ci) else None
1221        else:
1222            path = self._model.named_path_for_index(ci) if self.is_visible(ci) else None
1223        expanded_categories, state_map = self.get_state()
1224        self._model.rebuild_node_tree(state_map=state_map)
1225        self.blockSignals(True)
1226        for category in expanded_categories:
1227            idx = self._model.index_for_category(category)
1228            if idx is not None and idx.isValid():
1229                self.expand(idx)
1230        if path is not None:
1231            if use_pos:
1232                self.show_item_at_path(path)
1233            else:
1234                index = self._model.index_for_named_path(path)
1235                if index.isValid():
1236                    self.show_item_at_index(index)
1237        self.blockSignals(False)
1238
1239    def show_item_at_path(self, path, box=False,
1240                          position=QAbstractItemView.ScrollHint.PositionAtCenter):
1241        '''
1242        Scroll the browser and open categories to show the item referenced by
1243        path. If possible, the item is placed in the center. If box=True, a
1244        box is drawn around the item.
1245        '''
1246        if path:
1247            self.show_item_at_index(self._model.index_for_path(path), box=box,
1248                                    position=position)
1249
1250    def expand_parent(self, idx):
1251        # Needed otherwise Qt sometimes segfaults if the node is buried in a
1252        # collapsed, off screen hierarchy. To be safe, we expand from the
1253        # outermost in
1254        p = self._model.parent(idx)
1255        if p.isValid():
1256            self.expand_parent(p)
1257        self.expand(idx)
1258
1259    def show_item_at_index(self, idx, box=False,
1260                           position=QAbstractItemView.ScrollHint.PositionAtCenter):
1261        if idx.isValid() and idx.data(Qt.ItemDataRole.UserRole) is not self._model.root_item:
1262            self.expand_parent(idx)
1263            self.setCurrentIndex(idx)
1264            self.scrollTo(idx, position)
1265            if box:
1266                self._model.set_boxed(idx)
1267
1268    def item_expanded(self, idx):
1269        '''
1270        Called by the expanded signal
1271        '''
1272        self.setCurrentIndex(idx)
1273
1274    # }}}
1275