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, textwrap
10from functools import partial
11
12from qt.core import (
13    Qt, QIcon, QWidget, QHBoxLayout, QVBoxLayout, QToolButton, QLabel, QFrame, QDialog, QComboBox, QLineEdit,
14    QTimer, QMenu, QActionGroup, QAction, QSizePolicy, pyqtSignal)
15
16from calibre.gui2 import error_dialog, question_dialog, gprefs, config
17from calibre.gui2.widgets import HistoryLineEdit
18from calibre.library.field_metadata import category_icon_map
19from calibre.utils.icu import sort_key
20from calibre.gui2.tag_browser.view import TagsView
21from calibre.ebooks.metadata import title_sort
22from calibre.gui2.dialogs.tag_categories import TagCategories
23from calibre.gui2.dialogs.tag_list_editor import TagListEditor
24from calibre.gui2.dialogs.edit_authors_dialog import EditAuthorsDialog
25from polyglot.builtins import iteritems
26
27
28class TagBrowserMixin:  # {{{
29
30    def __init__(self, *args, **kwargs):
31        pass
32
33    def populate_tb_manage_menu(self, db):
34        from calibre.db.categories import find_categories
35        m = self.alter_tb.manage_menu
36        m.clear()
37        for text, func, args, cat_name in (
38             (_('Authors'),
39                        self.do_author_sort_edit, (self, None), 'authors'),
40             (ngettext('Series', 'Series', 2),
41                        self.do_tags_list_edit, (None, 'series'), 'series'),
42             (_('Publishers'),
43                        self.do_tags_list_edit, (None, 'publisher'), 'publisher'),
44             (_('Tags'),
45                        self.do_tags_list_edit, (None, 'tags'), 'tags'),
46             (_('User categories'),
47                        self.do_edit_user_categories, (None,), 'user:'),
48             (_('Saved searches'),
49                        self.do_saved_search_edit, (None,), 'search')
50            ):
51            m.addAction(QIcon(I(category_icon_map[cat_name])), text,
52                    partial(func, *args))
53        fm = db.new_api.field_metadata
54        categories = [x[0] for x in find_categories(fm) if fm.is_custom_field(x[0])]
55        if categories:
56            if len(categories) > 5:
57                m = m.addMenu(_('Custom columns'))
58            else:
59                m.addSeparator()
60
61            def cat_key(x):
62                try:
63                    return fm[x]['name']
64                except Exception:
65                    return ''
66            for cat in sorted(categories, key=cat_key):
67                name = cat_key(cat)
68                if name:
69                    m.addAction(name, partial(self.do_tags_list_edit, None, cat))
70
71    def init_tag_browser_mixin(self, db):
72        self.library_view.model().count_changed_signal.connect(self.tags_view.recount_with_position_based_index)
73        self.tags_view.set_database(db, self.alter_tb)
74        self.tags_view.tags_marked.connect(self.search.set_search_string)
75        self.tags_view.tags_list_edit.connect(self.do_tags_list_edit)
76        self.tags_view.edit_user_category.connect(self.do_edit_user_categories)
77        self.tags_view.delete_user_category.connect(self.do_delete_user_category)
78        self.tags_view.del_item_from_user_cat.connect(self.do_del_item_from_user_cat)
79        self.tags_view.add_subcategory.connect(self.do_add_subcategory)
80        self.tags_view.add_item_to_user_cat.connect(self.do_add_item_to_user_cat)
81        self.tags_view.saved_search_edit.connect(self.do_saved_search_edit)
82        self.tags_view.rebuild_saved_searches.connect(self.do_rebuild_saved_searches)
83        self.tags_view.author_sort_edit.connect(self.do_author_sort_edit)
84        self.tags_view.tag_item_renamed.connect(self.do_tag_item_renamed)
85        self.tags_view.search_item_renamed.connect(self.saved_searches_changed)
86        self.tags_view.drag_drop_finished.connect(self.drag_drop_finished)
87        self.tags_view.restriction_error.connect(self.do_restriction_error,
88                                                 type=Qt.ConnectionType.QueuedConnection)
89        self.tags_view.tag_item_delete.connect(self.do_tag_item_delete)
90        self.tags_view.tag_identifier_delete.connect(self.delete_identifier)
91        self.tags_view.apply_tag_to_selected.connect(self.apply_tag_to_selected)
92        self.populate_tb_manage_menu(db)
93        self.tags_view.model().user_categories_edited.connect(self.user_categories_edited,
94                type=Qt.ConnectionType.QueuedConnection)
95        self.tags_view.model().user_category_added.connect(self.user_categories_edited,
96                type=Qt.ConnectionType.QueuedConnection)
97        self.tags_view.edit_enum_values.connect(self.edit_enum_values)
98
99    def user_categories_edited(self):
100        self.library_view.model().refresh()
101
102    def do_restriction_error(self, e):
103        error_dialog(self.tags_view, _('Invalid search restriction'),
104                         _('The current search restriction is invalid'),
105                         det_msg=str(e) if e else '', show=True)
106
107    def do_add_subcategory(self, on_category_key, new_category_name=None):
108        '''
109        Add a subcategory to the category 'on_category'. If new_category_name is
110        None, then a default name is shown and the user is offered the
111        opportunity to edit the name.
112        '''
113        db = self.library_view.model().db
114        user_cats = db.new_api.pref('user_categories', {})
115
116        # Ensure that the temporary name we will use is not already there
117        i = 0
118        if new_category_name is not None:
119            new_name = new_category_name.replace('.', '')
120        else:
121            new_name = _('New category').replace('.', '')
122        n = new_name
123        while True:
124            new_cat = on_category_key[1:] + '.' + n
125            if new_cat not in user_cats:
126                break
127            i += 1
128            n = new_name + str(i)
129        # Add the new category
130        user_cats[new_cat] = []
131        db.new_api.set_pref('user_categories', user_cats)
132        self.tags_view.recount()
133        db.new_api.clear_search_caches()
134        m = self.tags_view.model()
135        idx = m.index_for_path(m.find_category_node('@' + new_cat))
136        self.tags_view.show_item_at_index(idx)
137        # Open the editor on the new item to rename it
138        if new_category_name is None:
139            item = m.get_node(idx)
140            item.use_vl = False
141            item.ignore_vl = True
142            self.tags_view.edit(idx)
143
144    def do_edit_user_categories(self, on_category=None):
145        '''
146        Open the User categories editor.
147        '''
148        db = self.library_view.model().db
149        d = TagCategories(self, db, on_category,
150                          book_ids=self.tags_view.model().get_book_ids_to_use())
151        if d.exec() == QDialog.DialogCode.Accepted:
152            # Order is important. The categories must be removed before setting
153            # the preference because setting the pref recomputes the dynamic categories
154            db.field_metadata.remove_user_categories()
155            db.new_api.set_pref('user_categories', d.categories)
156            db.new_api.refresh_search_locations()
157            self.tags_view.recount()
158            db.new_api.clear_search_caches()
159            self.user_categories_edited()
160
161    def do_delete_user_category(self, category_name):
162        '''
163        Delete the User category named category_name. Any leading '@' is removed
164        '''
165        if category_name.startswith('@'):
166            category_name = category_name[1:]
167        db = self.library_view.model().db
168        user_cats = db.new_api.pref('user_categories', {})
169        cat_keys = sorted(user_cats.keys(), key=sort_key)
170        has_children = False
171        found = False
172        for k in cat_keys:
173            if k == category_name:
174                found = True
175                has_children = len(user_cats[k])
176            elif k.startswith(category_name + '.'):
177                has_children = True
178        if not found:
179            return error_dialog(self.tags_view, _('Delete User category'),
180                         _('%s is not a User category')%category_name, show=True)
181        if has_children:
182            if not question_dialog(self.tags_view, _('Delete User category'),
183                                   _('%s contains items. Do you really '
184                                     'want to delete it?')%category_name):
185                return
186        for k in cat_keys:
187            if k == category_name:
188                del user_cats[k]
189            elif k.startswith(category_name + '.'):
190                del user_cats[k]
191        db.new_api.set_pref('user_categories', user_cats)
192        self.tags_view.recount()
193        db.new_api.clear_search_caches()
194        self.user_categories_edited()
195
196    def do_del_item_from_user_cat(self, user_cat, item_name, item_category):
197        '''
198        Delete the item (item_name, item_category) from the User category with
199        key user_cat. Any leading '@' characters are removed
200        '''
201        if user_cat.startswith('@'):
202            user_cat = user_cat[1:]
203        db = self.library_view.model().db
204        user_cats = db.new_api.pref('user_categories', {})
205        if user_cat not in user_cats:
206            error_dialog(self.tags_view, _('Remove category'),
207                         _('User category %s does not exist')%user_cat,
208                         show=True)
209            return
210        self.tags_view.model().delete_item_from_user_category(user_cat,
211                                                      item_name, item_category)
212        self.tags_view.recount()
213        db.new_api.clear_search_caches()
214        self.user_categories_edited()
215
216    def do_add_item_to_user_cat(self, dest_category, src_name, src_category):
217        '''
218        Add the item src_name in src_category to the User category
219        dest_category. Any leading '@' is removed
220        '''
221        db = self.library_view.model().db
222        user_cats = db.new_api.pref('user_categories', {})
223
224        if dest_category and dest_category.startswith('@'):
225            dest_category = dest_category[1:]
226
227        if dest_category not in user_cats:
228            return error_dialog(self.tags_view, _('Add to User category'),
229                    _('A User category %s does not exist')%dest_category, show=True)
230
231        # Now add the item to the destination User category
232        add_it = True
233        if src_category == 'news':
234            src_category = 'tags'
235        for tup in user_cats[dest_category]:
236            if src_name == tup[0] and src_category == tup[1]:
237                add_it = False
238        if add_it:
239            user_cats[dest_category].append([src_name, src_category, 0])
240        db.new_api.set_pref('user_categories', user_cats)
241        self.tags_view.recount()
242        db.new_api.clear_search_caches()
243        self.user_categories_edited()
244
245    def get_book_ids(self, use_virtual_library, db, category):
246        book_ids = None if not use_virtual_library else self.tags_view.model().get_book_ids_to_use()
247        data = db.new_api.get_categories(book_ids=book_ids)
248        if category in data:
249            result = [(t.id, t.original_name, t.count) for t in data[category] if t.count > 0]
250        else:
251            result = None
252        return result
253
254    def do_tags_list_edit(self, tag, category, is_first_letter=False):
255        '''
256        Open the 'manage_X' dialog where X == category. If tag is not None, the
257        dialog will position the editor on that item.
258        '''
259
260        db = self.current_db
261        if category == 'series':
262            key = lambda x:sort_key(title_sort(x))
263        else:
264            key = sort_key
265
266        d = TagListEditor(self, category=category,
267                          cat_name=db.field_metadata[category]['name'],
268                          tag_to_match=tag,
269                          get_book_ids=partial(self.get_book_ids, db=db, category=category),
270                          sorter=key, ttm_is_first_letter=is_first_letter,
271                          fm=db.field_metadata[category])
272        d.exec()
273        if d.result() == QDialog.DialogCode.Accepted:
274            to_rename = d.to_rename  # dict of old id to new name
275            to_delete = d.to_delete  # list of ids
276            orig_name = d.original_names  # dict of id: name
277
278            if (category in ['tags', 'series', 'publisher'] or
279                    db.new_api.field_metadata.is_custom_field(category)):
280                m = self.tags_view.model()
281                for item in to_delete:
282                    m.delete_item_from_all_user_categories(orig_name[item], category)
283                for old_id in to_rename:
284                    m.rename_item_in_all_user_categories(orig_name[old_id],
285                                            category, str(to_rename[old_id]))
286
287                db.new_api.remove_items(category, to_delete)
288                db.new_api.rename_items(category, to_rename, change_index=False)
289
290                # Clean up the library view
291                self.do_tag_item_renamed()
292                self.tags_view.recount()
293
294    def do_tag_item_delete(self, category, item_id, orig_name,
295                           restrict_to_book_ids=None, children=[]):
296        '''
297        Delete an item from some category.
298        '''
299        tag_names = []
300        for child in children:
301            if child.tag.is_editable:
302                tag_names.append(child.tag.original_name)
303        n = '\n   '.join(tag_names)
304        if n:
305            n = '%s:\n   %s\n%s:\n   %s'%(_('Item'), orig_name, _('Children'), n)
306        if n:
307            # Use a new "see this again" name to force the dialog to appear at
308            # least once, thus announcing the new feature.
309            skip_dialog_name = 'tag_item_delete_hierarchical'
310            if restrict_to_book_ids:
311                msg = _('%s and its children will be deleted from books '
312                        'in the Virtual library. Are you sure?')%orig_name
313            else:
314                msg = _('%s and its children will be deleted from all books. '
315                        'Are you sure?')%orig_name
316        else:
317            skip_dialog_name='tag_item_delete'
318            if restrict_to_book_ids:
319                msg = _('%s will be deleted from books in the Virtual library. Are you sure?')%orig_name
320            else:
321                msg = _('%s will be deleted from all books. Are you sure?')%orig_name
322        if not question_dialog(self.tags_view,
323                    title=_('Delete item'),
324                    msg='<p>'+ msg,
325                    det_msg=n,
326                    skip_dialog_name=skip_dialog_name,
327                    skip_dialog_msg=_('Show this confirmation again')):
328            return
329        ids_to_remove = []
330        if item_id is not None:
331            ids_to_remove.append(item_id)
332        for child in children:
333            if child.tag.is_editable:
334                ids_to_remove.append(child.tag.id)
335
336        self.current_db.new_api.remove_items(category, ids_to_remove,
337                                             restrict_to_book_ids=restrict_to_book_ids)
338        if restrict_to_book_ids is None:
339            m = self.tags_view.model()
340            m.delete_item_from_all_user_categories(orig_name, category)
341
342        # Clean up the library view
343        self.do_tag_item_renamed()
344        self.tags_view.recount()
345
346    def apply_tag_to_selected(self, field_name, item_name, remove):
347        db = self.current_db.new_api
348        fm = db.field_metadata.get(field_name)
349        if fm is None:
350            return
351        book_ids = self.library_view.get_selected_ids()
352        if not book_ids:
353            return error_dialog(self.library_view, _('No books selected'), _(
354                'You must select some books to apply {} to').format(item_name), show=True)
355        existing_values = db.all_field_for(field_name, book_ids)
356        series_index_field = None
357        if fm['datatype'] == 'series':
358            series_index_field = field_name + '_index'
359        changes = {}
360        for book_id, existing in iteritems(existing_values):
361            if isinstance(existing, tuple):
362                existing = list(existing)
363                if remove:
364                    try:
365                        existing.remove(item_name)
366                    except ValueError:
367                        continue
368                    changes[book_id] = existing
369                else:
370                    if item_name not in existing:
371                        changes[book_id] = existing + [item_name]
372            else:
373                if remove:
374                    if existing == item_name:
375                        changes[book_id] = None
376                else:
377                    if existing != item_name:
378                        changes[book_id] = item_name
379        if changes:
380            db.set_field(field_name, changes)
381            if series_index_field is not None:
382                for book_id in changes:
383                    si = db.get_next_series_num_for(item_name, field=field_name)
384                    db.set_field(series_index_field, {book_id: si})
385            self.library_view.model().refresh_ids(set(changes), current_row=self.library_view.currentIndex().row())
386            self.tags_view.recount_with_position_based_index()
387
388    def delete_identifier(self, name, in_vl):
389        d = self.current_db.new_api
390        changed = False
391        books_to_use = self.tags_view.model().get_book_ids_to_use() if in_vl else d.all_book_ids()
392        ids = d.all_field_for('identifiers', books_to_use)
393        new_ids = {}
394        for id_ in ids:
395            for identifier_type in ids[id_]:
396                if identifier_type == name:
397                    new_ids[id_] = copy.copy(ids[id_])
398                    new_ids[id_].pop(name)
399                    changed = True
400        if changed:
401            if in_vl:
402                msg = _('The identifier %s will be deleted from books in the '
403                        'current virtual library. Are you sure?')%name
404            else:
405                msg= _('The identifier %s will be deleted from all books. Are you sure?')%name
406            if not question_dialog(self,
407                title=_('Delete identifier'),
408                msg=msg,
409                skip_dialog_name='tag_browser_delete_identifiers',
410                skip_dialog_msg=_('Show this confirmation again')):
411                return
412            d.set_field('identifiers', new_ids)
413            self.tags_view.recount_with_position_based_index()
414
415    def edit_enum_values(self, parent, db, key):
416        from calibre.gui2.dialogs.enum_values_edit import EnumValuesEdit
417        d = EnumValuesEdit(parent, db, key)
418        d.exec()
419
420    def do_tag_item_renamed(self):
421        # Clean up library view and search
422        # get information to redo the selection
423        rows = [r.row() for r in
424                self.library_view.selectionModel().selectedRows()]
425        m = self.library_view.model()
426        ids = [m.id(r) for r in rows]
427
428        m.refresh(reset=False)
429        m.research()
430        self.library_view.select_rows(ids)
431        # refreshing the tags view happens at the emit()/call() site
432
433    def do_author_sort_edit(self, parent, id_, select_sort=True,
434                            select_link=False, is_first_letter=False,
435                            lookup_author=False):
436        '''
437        Open the manage authors dialog
438        '''
439
440        db = self.library_view.model().db
441        get_authors_func = partial(self.get_book_ids, db=db, category='authors')
442        if lookup_author:
443            for t in get_authors_func(use_virtual_library=False):
444                if t[1] == id_:
445                    id_ = t[0]
446                    break
447        editor = EditAuthorsDialog(parent, db, id_, select_sort, select_link,
448                                   get_authors_func, is_first_letter)
449        if editor.exec() == QDialog.DialogCode.Accepted:
450            # Save and restore the current selections. Note that some changes
451            # will cause sort orders to change, so don't bother with attempting
452            # to restore the position. Restoring the state has the side effect
453            # of refreshing book details.
454            with self.library_view.preserve_state(preserve_hpos=False, preserve_vpos=False):
455                affected_books, id_map = set(), {}
456                db = db.new_api
457                rename_map = {author_id:new_author for author_id, old_author, new_author, new_sort, new_link in editor.result if old_author != new_author}
458                if rename_map:
459                    affected_books, id_map = db.rename_items('authors', rename_map)
460                link_map = {id_map.get(author_id, author_id):new_link for author_id, old_author, new_author, new_sort, new_link in editor.result}
461                affected_books |= db.set_link_for_authors(link_map)
462                sort_map = {id_map.get(author_id, author_id):new_sort for author_id, old_author, new_author, new_sort, new_link in editor.result}
463                affected_books |= db.set_sort_for_authors(sort_map)
464                self.library_view.model().refresh_ids(affected_books, current_row=self.library_view.currentIndex().row())
465                self.tags_view.recount()
466
467    def drag_drop_finished(self, ids):
468        self.library_view.model().refresh_ids(ids)
469
470    def tb_category_visibility(self, category, operation):
471        '''
472        Hide or show categories in the tag browser. 'category' is the lookup key.
473        Operation can be:
474        - 'show' to show the category in the tag browser
475        - 'hide' to hide the category
476        - 'toggle' to invert its visibility
477        - 'is_visible' returns True if the category is currently visible, False otherwise
478        '''
479        if category not in self.tags_view.model().categories:
480            raise ValueError(_('change_tb_category_visibility: category %s does not exist') % category)
481        cats = self.tags_view.hidden_categories
482        if operation == 'hide':
483            cats.add(category)
484        elif operation == 'show':
485            cats.discard(category)
486        elif operation == 'toggle':
487            if category in cats:
488                cats.remove(category)
489            else:
490                cats.add(category)
491        elif operation == 'is_visible':
492            return category not in cats
493        else:
494            raise ValueError(_('change_tb_category_visibility: invalid operation %s') % operation)
495        self.library_view.model().db.new_api.set_pref('tag_browser_hidden_categories', list(cats))
496        self.tags_view.recount()
497
498# }}}
499
500
501class FindBox(HistoryLineEdit):  # {{{
502
503    def keyPressEvent(self, event):
504        k = event.key()
505        if k not in (Qt.Key.Key_Up, Qt.Key.Key_Down):
506            return HistoryLineEdit.keyPressEvent(self, event)
507        self.blockSignals(True)
508        if k == Qt.Key.Key_Down and self.currentIndex() == 0 and not self.lineEdit().text():
509            self.setCurrentIndex(1), self.setCurrentIndex(0)
510            event.accept()
511        else:
512            HistoryLineEdit.keyPressEvent(self, event)
513        self.blockSignals(False)
514# }}}
515
516
517class TagBrowserBar(QWidget):  # {{{
518
519    clear_find = pyqtSignal()
520
521    def __init__(self, parent):
522        QWidget.__init__(self, parent)
523        self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
524        parent = parent.parent()
525        self.l = l = QHBoxLayout(self)
526        l.setContentsMargins(0, 0, 0, 0)
527        self.alter_tb = parent.alter_tb = b = QToolButton(self)
528        b.setAutoRaise(True)
529        b.setText(_('Configure')), b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
530        b.setCursor(Qt.CursorShape.PointingHandCursor)
531        b.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
532        b.setToolTip(textwrap.fill(_(
533            'Change how the Tag browser works, such as,'
534            ' how it is sorted, what happens when you click'
535            ' items, etc.'
536        )))
537        b.setIcon(QIcon(I('config.png')))
538        b.m = QMenu(b)
539        b.setMenu(b.m)
540
541        self.item_search = FindBox(parent)
542        self.item_search.setMinimumContentsLength(5)
543        self.item_search.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
544        self.item_search.initialize('tag_browser_search')
545        self.item_search.completer().setCaseSensitivity(Qt.CaseSensitivity.CaseSensitive)
546        self.item_search.setToolTip(
547            '<p>' +_(
548                'Search for items. If the text begins with equals (=) the search is '
549                'exact match, otherwise it is "contains" finding items containing '
550                'the text anywhere in the item name. Both exact and contains '
551                'searches ignore case. You can limit the search to particular '
552                'categories using syntax similar to search. For example, '
553                'tags:foo will find foo in any tag, but not in authors etc. Entering '
554                '*foo will collapse all categories then showing only those categories '
555                'with items containing the text "foo"') + '</p>')
556        ac = QAction(parent)
557        parent.addAction(ac)
558        parent.keyboard.register_shortcut('tag browser find box',
559                _('Find in the Tag browser'), default_keys=(),
560                action=ac, group=_('Tag browser'))
561        ac.triggered.connect(self.set_focus_to_find_box)
562
563        self.search_button = QToolButton()
564        self.search_button.setAutoRaise(True)
565        self.search_button.setCursor(Qt.CursorShape.PointingHandCursor)
566        self.search_button.setIcon(QIcon(I('search.png')))
567        self.search_button.setToolTip(_('Find the first/next matching item'))
568        ac = QAction(parent)
569        parent.addAction(ac)
570        parent.keyboard.register_shortcut('tag browser find button',
571                _('Find next match'), default_keys=(),
572                action=ac, group=_('Tag browser'))
573        ac.triggered.connect(self.search_button.click)
574
575        self.toggle_search_button = b = QToolButton(self)
576        le = self.item_search.lineEdit()
577        le.addAction(QIcon(I('window-close.png')), QLineEdit.ActionPosition.LeadingPosition).triggered.connect(self.close_find_box)
578        b.setText(_('Find')), b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
579        b.setCursor(Qt.CursorShape.PointingHandCursor)
580        b.setIcon(QIcon(I('search.png')))
581        b.setCheckable(True)
582        b.setChecked(gprefs.get('tag browser search box visible', False))
583        b.setToolTip(_('Find item in the Tag browser'))
584        b.setAutoRaise(True)
585        b.toggled.connect(self.update_searchbar_state)
586        self.update_searchbar_state()
587
588    def close_find_box(self):
589        self.item_search.setCurrentIndex(0)
590        self.item_search.setCurrentText('')
591        self.toggle_search_button.click()
592        self.clear_find.emit()
593
594    def set_focus_to_find_box(self):
595        self.toggle_search_button.setChecked(True)
596        self.item_search.setFocus()
597        self.item_search.lineEdit().selectAll()
598
599    def update_searchbar_state(self):
600        find_shown = self.toggle_search_button.isChecked()
601        self.toggle_search_button.setVisible(not find_shown)
602        l = self.layout()
603        for i in (l.itemAt(i) for i in range(l.count())):
604            l.removeItem(i)
605        if find_shown:
606            l.addWidget(self.alter_tb)
607            self.alter_tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
608            l.addWidget(self.item_search, 10)
609            l.addWidget(self.search_button)
610            self.item_search.setFocus(Qt.FocusReason.OtherFocusReason)
611            self.toggle_search_button.setVisible(False)
612            self.search_button.setVisible(True)
613            self.item_search.setVisible(True)
614        else:
615            l.addWidget(self.alter_tb)
616            self.alter_tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
617            l.addStretch(10)
618            l.addStretch(10)
619            l.addWidget(self.toggle_search_button)
620            self.toggle_search_button.setVisible(True)
621            self.search_button.setVisible(False)
622            self.item_search.setVisible(False)
623
624# }}}
625
626
627class TagBrowserWidget(QFrame):  # {{{
628
629    def __init__(self, parent):
630        QFrame.__init__(self, parent)
631        self.setFrameStyle(QFrame.Shape.NoFrame if gprefs['tag_browser_old_look'] else QFrame.Shape.StyledPanel)
632        self._parent = parent
633        self._layout = QVBoxLayout(self)
634        self._layout.setContentsMargins(0,0,0,0)
635
636        # Set up the find box & button
637        self.tb_bar = tbb = TagBrowserBar(self)
638        tbb.clear_find.connect(self.reset_find)
639        self.alter_tb, self.item_search, self.search_button = tbb.alter_tb, tbb.item_search, tbb.search_button
640        self.toggle_search_button = tbb.toggle_search_button
641        self._layout.addWidget(tbb)
642
643        self.current_find_position = None
644        self.search_button.clicked.connect(self.find)
645        self.item_search.lineEdit().textEdited.connect(self.find_text_changed)
646        self.item_search.activated[str].connect(self.do_find)
647
648        # The tags view
649        parent.tags_view = TagsView(parent)
650        self.tags_view = parent.tags_view
651        self._layout.insertWidget(0, parent.tags_view)
652
653        # Now the floating 'not found' box
654        l = QLabel(self.tags_view)
655        self.not_found_label = l
656        l.setFrameStyle(QFrame.Shape.StyledPanel)
657        l.setAutoFillBackground(True)
658        l.setText('<p><b>'+_('No more matches.</b><p> Click Find again to go to first match'))
659        l.setAlignment(Qt.AlignmentFlag.AlignVCenter)
660        l.setWordWrap(True)
661        l.resize(l.sizeHint())
662        l.move(10,20)
663        l.setVisible(False)
664        self.not_found_label_timer = QTimer()
665        self.not_found_label_timer.setSingleShot(True)
666        self.not_found_label_timer.timeout.connect(self.not_found_label_timer_event,
667                                                   type=Qt.ConnectionType.QueuedConnection)
668        self.collapse_all_action = ac = QAction(parent)
669        parent.addAction(ac)
670        parent.keyboard.register_shortcut('tag browser collapse all',
671                _('Collapse all'), default_keys=(),
672                action=ac, group=_('Tag browser'))
673        connect_lambda(ac.triggered, self, lambda self: self.tags_view.collapseAll())
674
675        # The Configure Tag Browser button
676        l = self.alter_tb
677        ac = QAction(parent)
678        parent.addAction(ac)
679        parent.keyboard.register_shortcut('tag browser alter',
680                _('Configure Tag browser'), default_keys=(),
681                action=ac, group=_('Tag browser'))
682        ac.triggered.connect(l.showMenu)
683
684        l.m.aboutToShow.connect(self.about_to_show_configure_menu)
685        l.m.show_counts_action = ac = l.m.addAction('counts')
686        ac.triggered.connect(self.toggle_counts)
687        l.m.show_avg_rating_action = ac = l.m.addAction('avg rating')
688        ac.triggered.connect(self.toggle_avg_rating)
689        sb = l.m.addAction(_('Sort by'))
690        sb.m = l.sort_menu = QMenu(l.m)
691        sb.setMenu(sb.m)
692        sb.bg = QActionGroup(sb)
693
694        # Must be in the same order as db2.CATEGORY_SORTS
695        for i, x in enumerate((_('Name'), _('Number of books'),
696                  _('Average rating'))):
697            a = sb.m.addAction(x)
698            sb.bg.addAction(a)
699            a.setCheckable(True)
700            if i == 0:
701                a.setChecked(True)
702        sb.setToolTip(
703                _('Set the sort order for entries in the Tag browser'))
704        sb.setStatusTip(sb.toolTip())
705
706        ma = l.m.addAction(_('Search type when selecting multiple items'))
707        ma.m = l.match_menu = QMenu(l.m)
708        ma.setMenu(ma.m)
709        ma.ag = QActionGroup(ma)
710
711        # Must be in the same order as db2.MATCH_TYPE
712        for i, x in enumerate((_('Match any of the items'), _('Match all of the items'))):
713            a = ma.m.addAction(x)
714            ma.ag.addAction(a)
715            a.setCheckable(True)
716            if i == 0:
717                a.setChecked(True)
718        ma.setToolTip(
719                _('When selecting multiple entries in the Tag browser '
720                    'match any or all of them'))
721        ma.setStatusTip(ma.toolTip())
722
723        mt = l.m.addAction(_('Manage authors, tags, etc.'))
724        mt.setToolTip(_('All of these category_managers are available by right-clicking '
725                       'on items in the Tag browser above'))
726        mt.m = l.manage_menu = QMenu(l.m)
727        mt.setMenu(mt.m)
728
729        ac = QAction(parent)
730        parent.addAction(ac)
731        parent.keyboard.register_shortcut('tag browser toggle item',
732                _("'Click' found item"), default_keys=(),
733                action=ac, group=_('Tag browser'))
734        ac.triggered.connect(self.toggle_item)
735
736        ac = QAction(parent)
737        parent.addAction(ac)
738        parent.keyboard.register_shortcut('tag browser set focus',
739                _("Give the Tag browser keyboard focus"), default_keys=(),
740                action=ac, group=_('Tag browser'))
741        ac.triggered.connect(self.give_tb_focus)
742
743        # self.leak_test_timer = QTimer(self)
744        # self.leak_test_timer.timeout.connect(self.test_for_leak)
745        # self.leak_test_timer.start(5000)
746
747    def about_to_show_configure_menu(self):
748        ac = self.alter_tb.m.show_counts_action
749        ac.setText(_('Hide counts') if gprefs['tag_browser_show_counts'] else _('Show counts'))
750        ac = self.alter_tb.m.show_avg_rating_action
751        ac.setText(_('Hide average rating') if config['show_avg_rating'] else _('Show average rating'))
752
753    def toggle_counts(self):
754        gprefs['tag_browser_show_counts'] ^= True
755
756    def toggle_avg_rating(self):
757        config['show_avg_rating'] ^= True
758
759    def save_state(self):
760        gprefs.set('tag browser search box visible', self.toggle_search_button.isChecked())
761
762    def toggle_item(self):
763        self.tags_view.toggle_current_index()
764
765    def give_tb_focus(self, *args):
766        if gprefs['tag_browser_allow_keyboard_focus']:
767            tb = self.tags_view
768            if tb.hasFocus():
769                self._parent.shift_esc()
770            elif self._parent.current_view() == self._parent.library_view:
771                tb.setFocus()
772                idx = tb.currentIndex()
773                if not idx.isValid():
774                    idx = tb.model().createIndex(0, 0)
775                    tb.setCurrentIndex(idx)
776
777    def set_pane_is_visible(self, to_what):
778        self.tags_view.set_pane_is_visible(to_what)
779        if not to_what:
780            self._parent.shift_esc()
781
782    def find_text_changed(self, str_):
783        self.current_find_position = None
784
785    def set_focus_to_find_box(self):
786        self.tb_bar.set_focus_to_find_box()
787
788    def do_find(self, str_=None):
789        self.current_find_position = None
790        self.find()
791
792    @property
793    def find_text(self):
794        return str(self.item_search.currentText()).strip()
795
796    def reset_find(self):
797        model = self.tags_view.model()
798        model.clear_boxed()
799        if model.get_categories_filter():
800            model.set_categories_filter(None)
801            self.tags_view.recount()
802            self.current_find_position = None
803
804    def find(self):
805        model = self.tags_view.model()
806        model.clear_boxed()
807
808        # When a key is specified don't use the auto-collapsing search.
809        # A colon separates the lookup key from the search string.
810        # A leading colon says not to use autocollapsing search but search all keys
811        txt = self.find_text
812        colon = txt.find(':')
813        if colon >= 0:
814            key = self._parent.library_view.model().db.\
815                        field_metadata.search_term_to_field_key(txt[:colon])
816            if key in self._parent.library_view.model().db.field_metadata:
817                txt = txt[colon+1:]
818            else:
819                key = ''
820                txt = txt[1:] if colon == 0 else txt
821        else:
822            key = None
823
824        # key is None indicates that no colon was found.
825        # key == '' means either a leading : was found or the key is invalid
826
827        # At this point the txt might have a leading =, in which case do an
828        # exact match search
829
830        if (gprefs.get('tag_browser_always_autocollapse', False) and
831                key is None and not txt.startswith('*')):
832            txt = '*' + txt
833        if txt.startswith('*'):
834            self.tags_view.collapseAll()
835            model.set_categories_filter(txt[1:])
836            self.tags_view.recount()
837            self.current_find_position = None
838            return
839        if model.get_categories_filter():
840            model.set_categories_filter(None)
841            self.tags_view.recount()
842            self.current_find_position = None
843
844        if not txt:
845            return
846
847        self.item_search.lineEdit().blockSignals(True)
848        self.search_button.setFocus(Qt.FocusReason.OtherFocusReason)
849        self.item_search.lineEdit().blockSignals(False)
850
851        if txt.startswith('='):
852            equals_match = True
853            txt = txt[1:]
854        else:
855            equals_match = False
856        self.current_find_position = \
857            model.find_item_node(key, txt, self.current_find_position,
858                                 equals_match=equals_match)
859
860        if self.current_find_position:
861            self.tags_view.show_item_at_path(self.current_find_position, box=True)
862        elif self.item_search.text():
863            self.not_found_label.setVisible(True)
864            if self.tags_view.verticalScrollBar().isVisible():
865                sbw = self.tags_view.verticalScrollBar().width()
866            else:
867                sbw = 0
868            width = self.width() - 8 - sbw
869            height = self.not_found_label.heightForWidth(width) + 20
870            self.not_found_label.resize(width, height)
871            self.not_found_label.move(4, 10)
872            self.not_found_label_timer.start(2000)
873
874    def not_found_label_timer_event(self):
875        self.not_found_label.setVisible(False)
876
877    def keyPressEvent(self, ev):
878        if ev.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return) and self.item_search.hasFocus():
879            self.find()
880            ev.accept()
881            return
882        return QFrame.keyPressEvent(self, ev)
883
884
885# }}}
886