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