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