diff --git a/src/calibre/ebooks/compression/palmdoc.py b/src/calibre/ebooks/compression/palmdoc.py index 90dabcb5a8..6069777fab 100644 --- a/src/calibre/ebooks/compression/palmdoc.py +++ b/src/calibre/ebooks/compression/palmdoc.py @@ -17,6 +17,8 @@ def decompress_doc(data): return cPalmdoc.decompress(data) def compress_doc(data): + if not data: + return u'' return cPalmdoc.compress(data) def test(): diff --git a/src/calibre/gui2/actions/choose_library.py b/src/calibre/gui2/actions/choose_library.py index 32460b6bec..f96a261790 100644 --- a/src/calibre/gui2/actions/choose_library.py +++ b/src/calibre/gui2/actions/choose_library.py @@ -5,7 +5,7 @@ __license__ = 'GPL v3' __copyright__ = '2010, Kovid Goyal ' __docformat__ = 'restructuredtext en' -import os, shutil +import os from functools import partial from PyQt4.Qt import QMenu, Qt, QInputDialog, QToolButton @@ -14,7 +14,7 @@ from calibre import isbytestring from calibre.constants import filesystem_encoding, iswindows from calibre.utils.config import prefs from calibre.gui2 import (gprefs, warning_dialog, Dispatcher, error_dialog, - question_dialog, info_dialog) + question_dialog, info_dialog, open_local_file) from calibre.library.database2 import LibraryDatabase2 from calibre.gui2.actions import InterfaceAction @@ -107,7 +107,7 @@ class ChooseLibraryAction(InterfaceAction): self.quick_menu_action = self.choose_menu.addMenu(self.quick_menu) self.rename_menu = QMenu(_('Rename library')) self.rename_menu_action = self.choose_menu.addMenu(self.rename_menu) - self.delete_menu = QMenu(_('Delete library')) + self.delete_menu = QMenu(_('Remove library')) self.delete_menu_action = self.choose_menu.addMenu(self.delete_menu) ac = self.create_action(spec=(_('Pick a random book'), 'catalog.png', @@ -252,22 +252,15 @@ class ChooseLibraryAction(InterfaceAction): def delete_requested(self, name, location): loc = location.replace('/', os.sep) - if not question_dialog(self.gui, _('Are you sure?'), - _('

WARNING

')+ - _('All files (not just ebooks) ' - 'from

%s

will be ' - 'permanently deleted. Are you sure?') % loc, - show_copy_button=False, default_yes=False): - return - exists = self.gui.library_view.model().db.exists_at(loc) - if exists: - try: - shutil.rmtree(loc, ignore_errors=True) - except: - pass self.stats.remove(location) self.build_menus() self.gui.iactions['Copy To Library'].build_menus() + info_dialog(self.gui, _('Library removed'), + _('The library %s has been removed from calibre. ' + 'The files remain on your computer, if you want ' + 'to delete them, you will have to do so manually.') % loc, + show=True) + open_local_file(loc) def backup_status(self, location): dirty_text = 'no' diff --git a/src/calibre/gui2/init.py b/src/calibre/gui2/init.py index 874be6832f..67b4d5edd6 100644 --- a/src/calibre/gui2/init.py +++ b/src/calibre/gui2/init.py @@ -16,7 +16,7 @@ from calibre.constants import isosx, __appname__, preferred_encoding, \ from calibre.gui2 import config, is_widescreen, gprefs from calibre.gui2.library.views import BooksView, DeviceBooksView from calibre.gui2.widgets import Splitter -from calibre.gui2.tag_view import TagBrowserWidget +from calibre.gui2.tag_browser.ui import TagBrowserWidget from calibre.gui2.book_details import BookDetails from calibre.gui2.notify import get_notifier diff --git a/src/calibre/gui2/tag_browser/__init__.py b/src/calibre/gui2/tag_browser/__init__.py new file mode 100644 index 0000000000..cc6da1e995 --- /dev/null +++ b/src/calibre/gui2/tag_browser/__init__.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2011, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + + + diff --git a/src/calibre/gui2/tag_browser/model.py b/src/calibre/gui2/tag_browser/model.py new file mode 100644 index 0000000000..a80c3ceca2 --- /dev/null +++ b/src/calibre/gui2/tag_browser/model.py @@ -0,0 +1,1292 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) +from future_builtins import map + +__license__ = 'GPL v3' +__copyright__ = '2011, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +import traceback, cPickle, copy +from itertools import repeat + +from PyQt4.Qt import (QAbstractItemModel, QIcon, QVariant, QFont, Qt, + QMimeData, QModelIndex, pyqtSignal) + +from calibre.gui2 import NONE, gprefs, config, error_dialog +from calibre.library.database2 import Tag +from calibre.utils.config import tweaks +from calibre.utils.icu import sort_key, lower, strcmp +from calibre.library.field_metadata import TagsIcons, category_icon_map +from calibre.gui2.dialogs.confirm_delete import confirm +from calibre.utils.formatter import eval_formatter +from calibre.utils.search_query_parser import saved_searches + +TAG_SEARCH_STATES = {'clear': 0, 'mark_plus': 1, 'mark_plusplus': 2, + 'mark_minus': 3, 'mark_minusminus': 4} + +_bf = None +def bf(): + global _bf + if _bf is None: + _bf = QFont() + _bf.setBold(True) + _bf = QVariant(_bf) + return _bf + +class TagTreeItem(object): # {{{ + + CATEGORY = 0 + TAG = 1 + ROOT = 2 + + def __init__(self, data=None, category_icon=None, icon_map=None, + parent=None, tooltip=None, category_key=None, temporary=False): + self.parent = parent + self.children = [] + self.id_set = set() + self.is_gst = False + self.boxed = False + self.icon_state_map = list(map(QVariant, icon_map)) + if self.parent is not None: + self.parent.append(self) + + if data is None: + self.type = self.ROOT + else: + self.type = self.TAG if category_icon is None else self.CATEGORY + + if self.type == self.CATEGORY: + self.name, self.icon = map(QVariant, (data, category_icon)) + self.py_name = data + self.category_key = category_key + self.temporary = temporary + self.tag = Tag(data, category=category_key, + is_editable=category_key not in ['news', 'search', 'identifiers'], + is_searchable=category_key not in ['search']) + + elif self.type == self.TAG: + self.icon_state_map[0] = QVariant(data.icon) + self.tag = data + + self.tooltip = (tooltip + ' ') if tooltip else '' + + def break_cycles(self): + del self.parent + del self.children + + def __str__(self): + if self.type == self.ROOT: + return 'ROOT' + if self.type == self.CATEGORY: + return 'CATEGORY:'+str(QVariant.toString( + self.name))+':%d'%len(getattr(self, + 'children', [])) + return 'TAG: %s'%self.tag.name + + def row(self): + if self.parent is not None: + return self.parent.children.index(self) + return 0 + + def append(self, child): + child.parent = self + self.children.append(child) + + def data(self, role): + if role == Qt.UserRole: + return self + if self.type == self.TAG: + return self.tag_data(role) + if self.type == self.CATEGORY: + return self.category_data(role) + return NONE + + def category_data(self, role): + if role == Qt.DisplayRole: + return QVariant(self.py_name + ' [%d]'%len(self.child_tags())) + if role == Qt.EditRole: + return QVariant(self.py_name) + if role == Qt.DecorationRole: + if self.tag.state: + return self.icon_state_map[self.tag.state] + return self.icon + if role == Qt.FontRole: + return bf() + if role == Qt.ToolTipRole and self.tooltip is not None: + return QVariant(self.tooltip) + return NONE + + def tag_data(self, role): + tag = self.tag + if tag.use_sort_as_name: + name = tag.sort + tt_author = True + else: + p = self + while p.parent.type != self.ROOT: + p = p.parent + if not tag.is_hierarchical: + name = tag.original_name + else: + name = tag.name + tt_author = False + if role == Qt.DisplayRole: + count = len(self.id_set) + count = count if count > 0 else tag.count + if count == 0: + return QVariant('%s'%(name)) + else: + return QVariant('[%d] %s'%(count, name)) + if role == Qt.EditRole: + return QVariant(tag.original_name) + if role == Qt.DecorationRole: + return self.icon_state_map[tag.state] + if role == Qt.ToolTipRole: + if tt_author: + if tag.tooltip is not None: + return QVariant('(%s) %s'%(tag.name, tag.tooltip)) + else: + return QVariant(tag.name) + if tag.tooltip: + return QVariant(self.tooltip + tag.tooltip) + else: + return QVariant(self.tooltip) + return NONE + + def toggle(self, set_to=None): + ''' + set_to: None => advance the state, otherwise a value from TAG_SEARCH_STATES + ''' + if set_to is None: + while True: + self.tag.state = (self.tag.state + 1)%5 + if self.tag.state == TAG_SEARCH_STATES['mark_plus'] or \ + self.tag.state == TAG_SEARCH_STATES['mark_minus']: + if self.tag.is_searchable: + break + elif self.tag.state == TAG_SEARCH_STATES['mark_plusplus'] or\ + self.tag.state == TAG_SEARCH_STATES['mark_minusminus']: + if self.tag.is_searchable and len(self.children) and \ + self.tag.is_hierarchical == '5state': + break + else: + break + else: + self.tag.state = set_to + + def all_children(self): + res = [] + def recurse(nodes, res): + for t in nodes: + res.append(t) + recurse(t.children, res) + recurse(self.children, res) + return res + + def child_tags(self): + res = [] + def recurse(nodes, res): + for t in nodes: + if t.type != TagTreeItem.CATEGORY: + res.append(t) + recurse(t.children, res) + recurse(self.children, res) + return res + # }}} + +class TagsModel(QAbstractItemModel): # {{{ + + search_item_renamed = pyqtSignal() + tag_item_renamed = pyqtSignal() + refresh_required = pyqtSignal() + restriction_error = pyqtSignal() + drag_drop_finished = pyqtSignal(object) + user_categories_edited = pyqtSignal(object, object) + + def __init__(self, parent): + QAbstractItemModel.__init__(self, parent) + self.node_map = {} + self.category_nodes = [] + iconmap = {} + for key in category_icon_map: + iconmap[key] = QIcon(I(category_icon_map[key])) + self.category_icon_map = TagsIcons(iconmap) + self.categories_with_ratings = ['authors', 'series', 'publisher', 'tags'] + self.icon_state_map = [None, QIcon(I('plus.png')), QIcon(I('plusplus.png')), + QIcon(I('minus.png')), QIcon(I('minusminus.png'))] + + self.hidden_categories = set() + self.search_restriction = None + self.filter_categories_by = None + self.collapse_model = 'disable' + self.row_map = [] + self.root_item = self.create_node(icon_map=self.icon_state_map) + self.db = None + self.reread_collapse_model({}, rebuild=False) + + def reread_collapse_model(self, state_map, rebuild=True): + if gprefs['tags_browser_collapse_at'] == 0: + self.collapse_model = 'disable' + else: + self.collapse_model = gprefs['tags_browser_partition_method'] + if rebuild: + self.rebuild_node_tree(state_map) + + def set_search_restriction(self, s): + self.search_restriction = s + self.rebuild_node_tree() + + def set_database(self, db): + self.beginResetModel() + self.search_restriction = None + hidden_cats = db.prefs.get('tag_browser_hidden_categories', None) + # migrate from config to db prefs + if hidden_cats is None: + hidden_cats = config['tag_browser_hidden_categories'] + self.hidden_categories = set() + # strip out any non-existence field keys + for cat in hidden_cats: + if cat in db.field_metadata: + self.hidden_categories.add(cat) + db.prefs.set('tag_browser_hidden_categories', list(self.hidden_categories)) + + self.db = db + self._run_rebuild() + self.endResetModel() + + def rebuild_node_tree(self, state_map={}): + self.beginResetModel() + self._run_rebuild(state_map=state_map) + self.endResetModel() + + def _run_rebuild(self, state_map={}): + for node in self.node_map.itervalues(): + node.break_cycles() + del node #Clear reference to node in the current frame + self.node_map.clear() + self.category_nodes = [] + self.root_item = self.create_node(icon_map=self.icon_state_map) + self._rebuild_node_tree(state_map=state_map) + + def _rebuild_node_tree(self, state_map): + # Note that _get_category_nodes can indirectly change the + # user_categories dict. + data = self._get_category_nodes(config['sort_tags_by']) + gst = self.db.prefs.get('grouped_search_terms', {}) + + last_category_node = None + category_node_map = {} + self.category_node_tree = {} + for i, key in enumerate(self.row_map): + if self.hidden_categories: + if key in self.hidden_categories: + continue + found = False + for cat in self.hidden_categories: + if cat.startswith('@') and key.startswith(cat + '.'): + found = True + if found: + continue + is_gst = False + if key.startswith('@') and key[1:] in gst: + tt = _(u'The grouped search term name is "{0}"').format(key[1:]) + is_gst = True + elif key == 'news': + tt = '' + else: + tt = _(u'The lookup/search name is "{0}"').format(key) + + if key.startswith('@'): + path_parts = [p for p in key.split('.')] + path = '' + last_category_node = self.root_item + tree_root = self.category_node_tree + for i,p in enumerate(path_parts): + path += p + if path not in category_node_map: + node = self.create_node(parent=last_category_node, + data=p[1:] if i == 0 else p, + category_icon=self.category_icon_map[key], + tooltip=tt if path == key else path, + category_key=path, + icon_map=self.icon_state_map) + last_category_node = node + category_node_map[path] = node + self.category_nodes.append(node) + node.can_be_edited = (not is_gst) and (i == (len(path_parts)-1)) + node.is_gst = is_gst + if not is_gst: + node.tag.is_hierarchical = '5state' + if not is_gst: + tree_root[p] = {} + tree_root = tree_root[p] + else: + last_category_node = category_node_map[path] + tree_root = tree_root[p] + path += '.' + else: + node = self.create_node(parent=self.root_item, + data=self.categories[key], + category_icon=self.category_icon_map[key], + tooltip=tt, category_key=key, + icon_map=self.icon_state_map) + node.is_gst = False + category_node_map[key] = node + last_category_node = node + self.category_nodes.append(node) + self._create_node_tree(data, state_map) + + def _create_node_tree(self, data, state_map): + sort_by = config['sort_tags_by'] + + if data is None: + print ('_create_node_tree: no data!') + traceback.print_stack() + return + + collapse = gprefs['tags_browser_collapse_at'] + collapse_model = self.collapse_model + if collapse == 0: + collapse_model = 'disable' + elif collapse_model != 'disable': + if sort_by == 'name': + collapse_template = tweaks['categories_collapsed_name_template'] + elif sort_by == 'rating': + collapse_model = 'partition' + collapse_template = tweaks['categories_collapsed_rating_template'] + else: + collapse_model = 'partition' + collapse_template = tweaks['categories_collapsed_popularity_template'] + + def process_one_node(category, state_map): # {{{ + collapse_letter = None + category_node = category + key = category_node.category_key + if key not in data: + return + cat_len = len(data[key]) + if cat_len <= 0: + return + + category_child_map = {} + fm = self.db.field_metadata[key] + clear_rating = True if key not in self.categories_with_ratings and \ + not fm['is_custom'] and \ + not fm['kind'] == 'user' \ + else False + in_uc = fm['kind'] == 'user' + tt = key if in_uc else None + + if collapse_model == 'first letter': + # Build a list of 'equal' first letters by looking for + # overlapping ranges. If a range overlaps another, then the + # letters are assumed to be equivalent. ICU collating is complex + # beyond belief. This mechanism lets us determine the logical + # first character from ICU's standpoint. + chardict = {} + for idx,tag in enumerate(data[key]): + if not tag.sort: + c = ' ' + else: + c = icu_upper(tag.sort[0]) + if c not in chardict: + chardict[c] = [idx, idx] + else: + chardict[c][1] = idx + + # sort the ranges to facilitate detecting overlap + ranges = sorted([(v[0], v[1], c) for c,v in chardict.items()]) + + # Create a list of 'first letters' to use for each item in + # the category. The list is generated using the ranges. Overlaps + # are filled with the character that first occurs. + cl_list = list(repeat(None, len(data[key]))) + for t in ranges: + start = t[0] + c = t[2] + if cl_list[start] is None: + nc = c + else: + nc = cl_list[start] + for i in range(start, t[1]+1): + cl_list[i] = nc + + for idx,tag in enumerate(data[key]): + if clear_rating: + tag.avg_rating = None + tag.state = state_map.get((tag.name, tag.category), 0) + + if collapse_model != 'disable' and cat_len > collapse: + if collapse_model == 'partition': + if (idx % collapse) == 0: + d = {'first': tag} + if cat_len > idx + collapse: + d['last'] = data[key][idx+collapse-1] + else: + d['last'] = data[key][cat_len-1] + name = eval_formatter.safe_format(collapse_template, + d, 'TAG_VIEW', None) + sub_cat = self.create_node(parent=category, data = name, + tooltip = None, temporary=True, + category_icon = category_node.icon, + category_key=category_node.category_key, + icon_map=self.icon_state_map) + sub_cat.tag.is_searchable = False + else: # by 'first letter' + cl = cl_list[idx] + if cl != collapse_letter: + collapse_letter = cl + sub_cat = self.create_node(parent=category, + data = collapse_letter, + category_icon = category_node.icon, + tooltip = None, temporary=True, + category_key=category_node.category_key, + icon_map=self.icon_state_map) + node_parent = sub_cat + else: + node_parent = category + + # category display order is important here. The following works + # only of all the non-user categories are displayed before the + # user categories + components = [t.strip() for t in tag.original_name.split('.') + if t.strip()] + if len(components) == 0 or '.'.join(components) != tag.original_name: + components = [tag.original_name] + if (not tag.is_hierarchical) and (in_uc or + (fm['is_custom'] and fm['display'].get('is_names', False)) or + key in ['authors', 'publisher', 'news', 'formats', 'rating'] or + key not in self.db.prefs.get('categories_using_hierarchy', []) or + len(components) == 1): + n = self.create_node(parent=node_parent, data=tag, tooltip=tt, + icon_map=self.icon_state_map) + if tag.id_set is not None: + n.id_set |= tag.id_set + category_child_map[tag.name, tag.category] = n + else: + for i,comp in enumerate(components): + if i == 0: + child_map = category_child_map + else: + child_map = dict([((t.tag.name, t.tag.category), t) + for t in node_parent.children + if t.type != TagTreeItem.CATEGORY]) + if (comp,tag.category) in child_map: + node_parent = child_map[(comp,tag.category)] + node_parent.tag.is_hierarchical = \ + '5state' if tag.category != 'search' else '3state' + else: + if i < len(components)-1: + t = copy.copy(tag) + t.original_name = '.'.join(components[:i+1]) + if key != 'search': + # This 'manufactured' intermediate node can + # be searched, but cannot be edited. + t.is_editable = False + else: + t.is_searchable = t.is_editable = False + else: + t = tag + if not in_uc: + t.original_name = t.name + t.is_hierarchical = \ + '5state' if t.category != 'search' else '3state' + t.name = comp + node_parent = self.create_node(parent=node_parent, data=t, + tooltip=tt, icon_map=self.icon_state_map) + child_map[(comp,tag.category)] = node_parent + # This id_set must not be None + node_parent.id_set |= tag.id_set + return + # }}} + + for category in self.category_nodes: + process_one_node(category, state_map.get(category.py_name, {})) + + # Drag'n Drop {{{ + def mimeTypes(self): + return ["application/calibre+from_library", + 'application/calibre+from_tag_browser'] + + def mimeData(self, indexes): + data = [] + for idx in indexes: + if idx.isValid(): + # get some useful serializable data + node = self.get_node(idx) + path = self.path_for_index(idx) + if node.type == TagTreeItem.CATEGORY: + d = (node.type, node.py_name, node.category_key) + else: + t = node.tag + p = node + while p.type != TagTreeItem.CATEGORY: + p = p.parent + d = (node.type, p.category_key, p.is_gst, t.original_name, + t.category, path) + data.append(d) + else: + data.append(None) + raw = bytearray(cPickle.dumps(data, -1)) + ans = QMimeData() + ans.setData('application/calibre+from_tag_browser', raw) + return ans + + def dropMimeData(self, md, action, row, column, parent): + fmts = set([unicode(x) for x in md.formats()]) + if not fmts.intersection(set(self.mimeTypes())): + return False + if "application/calibre+from_library" in fmts: + if action != Qt.CopyAction: + return False + return self.do_drop_from_library(md, action, row, column, parent) + elif 'application/calibre+from_tag_browser' in fmts: + return self.do_drop_from_tag_browser(md, action, row, column, parent) + + def do_drop_from_tag_browser(self, md, action, row, column, parent): + if not parent.isValid(): + return False + dest = self.get_node(parent) + if dest.type != TagTreeItem.CATEGORY: + return False + if not md.hasFormat('application/calibre+from_tag_browser'): + return False + data = str(md.data('application/calibre+from_tag_browser')) + src = cPickle.loads(data) + for s in src: + if s[0] != TagTreeItem.TAG: + return False + return self.move_or_copy_item_to_user_category(src, dest, action) + + def move_or_copy_item_to_user_category(self, src, dest, action): + ''' + src is a list of tuples representing items to copy. The tuple is + (type, containing category key, category key is global search term, + full name, category key, path to node) + The type must be TagTreeItem.TAG + dest is the TagTreeItem node to receive the items + action is Qt.CopyAction or Qt.MoveAction + ''' + def process_source_node(user_cats, src_parent, src_parent_is_gst, + is_uc, dest_key, node): + ''' + Copy/move an item and all its children to the destination + ''' + copied = False + src_name = node.tag.original_name + src_cat = node.tag.category + # delete the item if the source is a user category and action is move + if is_uc and not src_parent_is_gst and src_parent in user_cats and \ + action == Qt.MoveAction: + new_cat = [] + for tup in user_cats[src_parent]: + if src_name == tup[0] and src_cat == tup[1]: + continue + new_cat.append(list(tup)) + user_cats[src_parent] = new_cat + else: + copied = True + + # Now add the item to the destination user category + add_it = True + if not is_uc and src_cat == 'news': + src_cat = 'tags' + for tup in user_cats[dest_key]: + if src_name == tup[0] and src_cat == tup[1]: + add_it = False + if add_it: + user_cats[dest_key].append([src_name, src_cat, 0]) + + for c in node.children: + copied = process_source_node(user_cats, src_parent, src_parent_is_gst, + is_uc, dest_key, c) + return copied + + user_cats = self.db.prefs.get('user_categories', {}) + path = None + for s in src: + src_parent, src_parent_is_gst = s[1:3] + path = s[5] + + if src_parent.startswith('@'): + is_uc = True + src_parent = src_parent[1:] + else: + is_uc = False + dest_key = dest.category_key[1:] + + if dest_key not in user_cats: + continue + + node = self.index_for_path(path) + if node: + process_source_node(user_cats, src_parent, src_parent_is_gst, + is_uc, dest_key, + self.get_node(node)) + + self.db.prefs.set('user_categories', user_cats) + self.refresh_required.emit() + + return True + + def do_drop_from_library(self, md, action, row, column, parent): + idx = parent + if idx.isValid(): + node = self.data(idx, Qt.UserRole) + if node.type == TagTreeItem.TAG: + fm = self.db.metadata_for_field(node.tag.category) + if node.tag.category in \ + ('tags', 'series', 'authors', 'rating', 'publisher') or \ + (fm['is_custom'] and ( + fm['datatype'] in ['text', 'rating', 'series', + 'enumeration'] or + (fm['datatype'] == 'composite' and + fm['display'].get('make_category', False)))): + mime = 'application/calibre+from_library' + ids = list(map(int, str(md.data(mime)).split())) + self.handle_drop(node, ids) + return True + elif node.type == TagTreeItem.CATEGORY: + fm_dest = self.db.metadata_for_field(node.category_key) + if fm_dest['kind'] == 'user': + fm_src = self.db.metadata_for_field(md.column_name) + if md.column_name in ['authors', 'publisher', 'series'] or \ + (fm_src['is_custom'] and ( + (fm_src['datatype'] in ['series', 'text', 'enumeration'] and + not fm_src['is_multiple']))or + (fm_src['datatype'] == 'composite' and + fm_src['display'].get('make_category', False))): + mime = 'application/calibre+from_library' + ids = list(map(int, str(md.data(mime)).split())) + self.handle_user_category_drop(node, ids, md.column_name) + return True + return False + + def handle_user_category_drop(self, on_node, ids, column): + categories = self.db.prefs.get('user_categories', {}) + category = categories.get(on_node.category_key[1:], None) + if category is None: + return + fm_src = self.db.metadata_for_field(column) + for id in ids: + label = fm_src['label'] + if not fm_src['is_custom']: + if label == 'authors': + items = self.db.get_authors_with_ids() + items = [(i[0], i[1].replace('|', ',')) for i in items] + value = self.db.authors(id, index_is_id=True) + value = [v.replace('|', ',') for v in value.split(',')] + elif label == 'publisher': + items = self.db.get_publishers_with_ids() + value = self.db.publisher(id, index_is_id=True) + elif label == 'series': + items = self.db.get_series_with_ids() + value = self.db.series(id, index_is_id=True) + else: + items = self.db.get_custom_items_with_ids(label=label) + if fm_src['datatype'] != 'composite': + value = self.db.get_custom(id, label=label, index_is_id=True) + else: + value = self.db.get_property(id, loc=fm_src['rec_index'], + index_is_id=True) + if value is None: + return + if not isinstance(value, list): + value = [value] + for val in value: + for (v, c, id) in category: + if v == val and c == column: + break + else: + category.append([val, column, 0]) + categories[on_node.category_key[1:]] = category + self.db.prefs.set('user_categories', categories) + self.refresh_required.emit() + + def handle_drop(self, on_node, ids): + #print 'Dropped ids:', ids, on_node.tag + key = on_node.tag.category + if (key == 'authors' and len(ids) >= 5): + if not confirm('

'+_('Changing the authors for several books can ' + 'take a while. Are you sure?') + +'

', 'tag_browser_drop_authors', self.parent()): + return + elif len(ids) > 15: + if not confirm('

'+_('Changing the metadata for that many books ' + 'can take a while. Are you sure?') + +'

', 'tag_browser_many_changes', self.parent()): + return + + fm = self.db.metadata_for_field(key) + is_multiple = fm['is_multiple'] + val = on_node.tag.original_name + for id in ids: + mi = self.db.get_metadata(id, index_is_id=True) + + # Prepare to ignore the author, unless it is changed. Title is + # always ignored -- see the call to set_metadata + set_authors = False + + # Author_sort cannot change explicitly. Changing the author might + # change it. + mi.author_sort = None # Never will change by itself. + + if key == 'authors': + mi.authors = [val] + set_authors=True + elif fm['datatype'] == 'rating': + mi.set(key, len(val) * 2) + elif fm['is_custom'] and fm['datatype'] == 'series': + mi.set(key, val, extra=1.0) + elif is_multiple: + new_val = mi.get(key, []) + if val in new_val: + # Fortunately, only one field can change, so the continue + # won't break anything + continue + new_val.append(val) + mi.set(key, new_val) + else: + mi.set(key, val) + self.db.set_metadata(id, mi, set_title=False, + set_authors=set_authors, commit=False) + self.db.commit() + self.drag_drop_finished.emit(ids) + # }}} + + def _get_category_nodes(self, sort): + ''' + Called by __init__. Do not directly call this method. + ''' + self.row_map = [] + self.categories = {} + + # Get the categories + if self.search_restriction: + try: + data = self.db.get_categories(sort=sort, + icon_map=self.category_icon_map, + ids=self.db.search('', return_matches=True)) + except: + data = self.db.get_categories(sort=sort, icon_map=self.category_icon_map) + self.restriction_error.emit() + else: + data = self.db.get_categories(sort=sort, icon_map=self.category_icon_map) + + # Reconstruct the user categories, putting them into metadata + self.db.field_metadata.remove_dynamic_categories() + tb_cats = self.db.field_metadata + for user_cat in sorted(self.db.prefs.get('user_categories', {}).keys(), + key=sort_key): + cat_name = '@' + user_cat # add the '@' to avoid name collision + while True: + try: + tb_cats.add_user_category(label=cat_name, name=user_cat) + dot = cat_name.rfind('.') + if dot < 0: + break + cat_name = cat_name[:dot] + except ValueError: + break + + for cat in sorted(self.db.prefs.get('grouped_search_terms', {}).keys(), + key=sort_key): + if (u'@' + cat) in data: + try: + tb_cats.add_user_category(label=u'@' + cat, name=cat) + except ValueError: + traceback.print_exc() + self.db.data.change_search_locations(self.db.field_metadata.get_search_terms()) + + if len(saved_searches().names()): + tb_cats.add_search_category(label='search', name=_('Searches')) + + if self.filter_categories_by: + for category in data.keys(): + data[category] = [t for t in data[category] + if lower(t.name).find(self.filter_categories_by) >= 0] + + tb_categories = self.db.field_metadata + for category in tb_categories: + if category in data: # The search category can come and go + self.row_map.append(category) + self.categories[category] = tb_categories[category]['name'] + return data + + def refresh(self, data=None): + ''' + Here to trap usages of refresh in the old architecture. Can eventually + be removed. + ''' + print ('TagsModel: refresh called!') + traceback.print_stack() + return False + + def create_node(self, *args, **kwargs): + node = TagTreeItem(*args, **kwargs) + self.node_map[id(node)] = node + return node + + def get_node(self, idx): + ans = self.node_map.get(idx.internalId(), self.root_item) + return ans + + def createIndex(self, row, column, internal_pointer=None): + idx = QAbstractItemModel.createIndex(self, row, column, + id(internal_pointer)) + return idx + + def index_for_category(self, name): + for row, category in enumerate(self.category_nodes): + if category.py_name == name: + return self.index(row, 0, QModelIndex()) + + def columnCount(self, parent): + return 1 + + def data(self, index, role): + if not index.isValid(): + return NONE + item = self.get_node(index) + return item.data(role) + + def setData(self, index, value, role=Qt.EditRole): + if not index.isValid(): + return False + # set up to reposition at the same item. We can do this except if + # working with the last item and that item is deleted, in which case + # we position at the parent label + val = unicode(value.toString()).strip() + if not val: + error_dialog(self.parent(), _('Item is blank'), + _('An item cannot be set to nothing. Delete it instead.')).exec_() + return False + item = self.get_node(index) + if item.type == TagTreeItem.CATEGORY and item.category_key.startswith('@'): + if val.find('.') >= 0: + error_dialog(self.parent(), _('Rename user category'), + _('You cannot use periods in the name when ' + 'renaming user categories'), show=True) + return False + + user_cats = self.db.prefs.get('user_categories', {}) + user_cat_keys_lower = [icu_lower(k) for k in user_cats] + ckey = item.category_key[1:] + ckey_lower = icu_lower(ckey) + dotpos = ckey.rfind('.') + if dotpos < 0: + nkey = val + else: + nkey = ckey[:dotpos+1] + val + nkey_lower = icu_lower(nkey) + for c in sorted(user_cats.keys(), key=sort_key): + if icu_lower(c).startswith(ckey_lower): + if len(c) == len(ckey): + if strcmp(ckey, nkey) != 0 and \ + nkey_lower in user_cat_keys_lower: + error_dialog(self.parent(), _('Rename user category'), + _('The name %s is already used')%nkey, show=True) + return False + user_cats[nkey] = user_cats[ckey] + del user_cats[ckey] + elif c[len(ckey)] == '.': + rest = c[len(ckey):] + if strcmp(ckey, nkey) != 0 and \ + icu_lower(nkey + rest) in user_cat_keys_lower: + error_dialog(self.parent(), _('Rename user category'), + _('The name %s is already used')%(nkey+rest), show=True) + return False + user_cats[nkey + rest] = user_cats[ckey + rest] + del user_cats[ckey + rest] + self.user_categories_edited.emit(user_cats, nkey) # Does a refresh + return True + + key = item.tag.category + name = item.tag.original_name + # make certain we know about the item's category + if key not in self.db.field_metadata: + return False + if key == 'authors': + if val.find('&') >= 0: + error_dialog(self.parent(), _('Invalid author name'), + _('Author names cannot contain & characters.')).exec_() + return False + if key == 'search': + if val in saved_searches().names(): + error_dialog(self.parent(), _('Duplicate search name'), + _('The saved search name %s is already used.')%val).exec_() + return False + saved_searches().rename(unicode(item.data(role).toString()), val) + item.tag.name = val + self.search_item_renamed.emit() # Does a refresh + else: + if key == 'series': + self.db.rename_series(item.tag.id, val) + elif key == 'publisher': + self.db.rename_publisher(item.tag.id, val) + elif key == 'tags': + self.db.rename_tag(item.tag.id, val) + elif key == 'authors': + self.db.rename_author(item.tag.id, val) + elif self.db.field_metadata[key]['is_custom']: + self.db.rename_custom_item(item.tag.id, val, + label=self.db.field_metadata[key]['label']) + self.tag_item_renamed.emit() + item.tag.name = val + self.rename_item_in_all_user_categories(name, key, val) + self.refresh_required.emit() + return True + + def rename_item_in_all_user_categories(self, item_name, item_category, new_name): + ''' + Search all user categories for items named item_name with category + item_category and rename them to new_name. The caller must arrange to + redisplay the tree as appropriate. + ''' + user_cats = self.db.prefs.get('user_categories', {}) + for k in user_cats.keys(): + new_contents = [] + for tup in user_cats[k]: + if tup[0] == item_name and tup[1] == item_category: + new_contents.append([new_name, item_category, 0]) + else: + new_contents.append(tup) + user_cats[k] = new_contents + self.db.prefs.set('user_categories', user_cats) + + def delete_item_from_all_user_categories(self, item_name, item_category): + ''' + Search all user categories for items named item_name with category + item_category and delete them. The caller must arrange to redisplay the + tree as appropriate. + ''' + user_cats = self.db.prefs.get('user_categories', {}) + for cat in user_cats.keys(): + self.delete_item_from_user_category(cat, item_name, item_category, + user_categories=user_cats) + self.db.prefs.set('user_categories', user_cats) + + def delete_item_from_user_category(self, category, item_name, item_category, + user_categories=None): + if user_categories is not None: + user_cats = user_categories + else: + user_cats = self.db.prefs.get('user_categories', {}) + new_contents = [] + for tup in user_cats[category]: + if tup[0] != item_name or tup[1] != item_category: + new_contents.append(tup) + user_cats[category] = new_contents + if user_categories is None: + self.db.prefs.set('user_categories', user_cats) + + def headerData(self, *args): + return NONE + + def flags(self, index, *args): + ans = Qt.ItemIsEnabled|Qt.ItemIsSelectable|Qt.ItemIsEditable + if index.isValid(): + node = self.data(index, Qt.UserRole) + if node.type == TagTreeItem.TAG: + if node.tag.is_editable: + ans |= Qt.ItemIsDragEnabled + fm = self.db.metadata_for_field(node.tag.category) + if node.tag.category in \ + ('tags', 'series', 'authors', 'rating', 'publisher') or \ + (fm['is_custom'] and \ + fm['datatype'] in ['text', 'rating', 'series', 'enumeration']): + ans |= Qt.ItemIsDropEnabled + else: + ans |= Qt.ItemIsDropEnabled + return ans + + def supportedDropActions(self): + return Qt.CopyAction|Qt.MoveAction + + def path_for_index(self, index): + ans = [] + while index.isValid(): + ans.append(index.row()) + index = self.parent(index) + ans.reverse() + return ans + + def index_for_path(self, path): + parent = QModelIndex() + for i in path: + parent = self.index(i, 0, parent) + if not parent.isValid(): + return QModelIndex() + return parent + + def index(self, row, column, parent): + if not self.hasIndex(row, column, parent): + return QModelIndex() + + if not parent.isValid(): + parent_item = self.root_item + else: + parent_item = self.get_node(parent) + + try: + child_item = parent_item.children[row] + except IndexError: + return QModelIndex() + + ans = self.createIndex(row, column, child_item) + return ans + + def parent(self, index): + if not index.isValid(): + return QModelIndex() + + child_item = self.get_node(index) + parent_item = getattr(child_item, 'parent', None) + + if parent_item is self.root_item or parent_item is None: + return QModelIndex() + + ans = self.createIndex(parent_item.row(), 0, parent_item) + return ans + + def rowCount(self, parent): + if parent.column() > 0: + return 0 + + if not parent.isValid(): + parent_item = self.root_item + else: + parent_item = self.get_node(parent) + + return len(parent_item.children) + + def reset_all_states(self, except_=None): + update_list = [] + def process_tag(tag_item): + tag = tag_item.tag + if tag is except_: + tag_index = self.createIndex(tag_item.row(), 0, tag_item) + self.dataChanged.emit(tag_index, tag_index) + elif tag.state != 0 or tag in update_list: + tag_index = self.createIndex(tag_item.row(), 0, tag_item) + tag.state = 0 + update_list.append(tag) + self.dataChanged.emit(tag_index, tag_index) + for t in tag_item.children: + process_tag(t) + + for t in self.root_item.children: + process_tag(t) + + def clear_state(self): + self.reset_all_states() + + def toggle(self, index, exclusive, set_to=None): + ''' + exclusive: clear all states before applying this one + set_to: None => advance the state, otherwise a value from TAG_SEARCH_STATES + ''' + if not index.isValid(): return False + item = self.get_node(index) + item.toggle(set_to=set_to) + if exclusive: + self.reset_all_states(except_=item.tag) + self.dataChanged.emit(index, index) + return True + + def tokens(self): + ans = [] + # Tags can be in the news and the tags categories. However, because of + # the desire to use two different icons (tags and news), the nodes are + # not shared, which can lead to the possibility of searching twice for + # the same tag. The tags_seen set helps us prevent that + tags_seen = set() + # Tag nodes are in their own category and possibly in user categories. + # They will be 'checked' in both places, but we want to put the node + # into the search string only once. The nodes_seen set helps us do that + nodes_seen = set() + + node_searches = {TAG_SEARCH_STATES['mark_plus'] : 'true', + TAG_SEARCH_STATES['mark_plusplus'] : '.true', + TAG_SEARCH_STATES['mark_minus'] : 'false', + TAG_SEARCH_STATES['mark_minusminus'] : '.false'} + + for node in self.category_nodes: + if node.tag.state: + if node.category_key == "news": + if node_searches[node.tag.state] == 'true': + ans.append('tags:"=' + _('News') + '"') + else: + ans.append('( not tags:"=' + _('News') + '")') + else: + ans.append('%s:%s'%(node.category_key, node_searches[node.tag.state])) + + key = node.category_key + for tag_item in node.all_children(): + if tag_item.type == TagTreeItem.CATEGORY: + if self.collapse_model == 'first letter' and \ + tag_item.temporary and not key.startswith('@') \ + and tag_item.tag.state: + k = 'author_sort' if key == 'authors' else key + letters_seen = {} + for subnode in tag_item.children: + letters_seen[subnode.tag.sort[0]] = True + charclass = ''.join(letters_seen) + if k == 'author_sort': + expr = r'%s:"~(^[%s])|(&\\s*[%s])"'%(k, charclass, charclass) + else: + expr = r'%s:"~^[%s]"'%(k, charclass) + if node_searches[tag_item.tag.state] == 'true': + ans.append(expr) + else: + ans.append('(not ' + expr + ')') + continue + tag = tag_item.tag + if tag.state != TAG_SEARCH_STATES['clear']: + if tag.state == TAG_SEARCH_STATES['mark_minus'] or \ + tag.state == TAG_SEARCH_STATES['mark_minusminus']: + prefix = ' not ' + else: + prefix = '' + category = tag.category if key != 'news' else 'tag' + add_colon = False + if self.db.field_metadata[tag.category]['is_csp']: + add_colon = True + + if tag.name and tag.name[0] == u'\u2605': # char is a star. Assume rating + ans.append('%s%s:%s'%(prefix, category, len(tag.name))) + else: + name = tag.original_name + use_prefix = tag.state in [TAG_SEARCH_STATES['mark_plusplus'], + TAG_SEARCH_STATES['mark_minusminus']] + if category == 'tags': + if name in tags_seen: + continue + tags_seen.add(name) + if tag in nodes_seen: + continue + nodes_seen.add(tag) + n = name.replace(r'"', r'\"') + if name.startswith('.'): + n = '.' + n + ans.append('%s%s:"=%s%s%s"'%(prefix, category, + '.' if use_prefix else '', n, + ':' if add_colon else '')) + return ans + + def find_item_node(self, key, txt, start_path, equals_match=False): + ''' + Search for an item (a node) in the tags browser list that matches both + the key (exact case-insensitive match) and txt (not equals_match => + case-insensitive contains match; equals_match => case_insensitive + equal match). Returns the path to the node. Note that paths are to a + location (second item, fourth item, 25 item), not to a node. If + start_path is None, the search starts with the topmost node. If the tree + is changed subsequent to calling this method, the path can easily refer + to a different node or no node at all. + ''' + if not txt: + return None + txt = lower(txt) if not equals_match else txt + self.path_found = None + if start_path is None: + start_path = [] + + def process_tag(depth, tag_index, tag_item, start_path): + path = self.path_for_index(tag_index) + if depth < len(start_path) and path[depth] <= start_path[depth]: + return False + tag = tag_item.tag + if tag is None: + return False + name = tag.original_name + if (equals_match and strcmp(name, txt) == 0) or \ + (not equals_match and lower(name).find(txt) >= 0): + self.path_found = path + return True + for i,c in enumerate(tag_item.children): + if process_tag(depth+1, self.createIndex(i, 0, c), c, start_path): + return True + return False + + def process_level(depth, category_index, start_path): + path = self.path_for_index(category_index) + if depth < len(start_path): + if path[depth] < start_path[depth]: + return False + if path[depth] > start_path[depth]: + start_path = path + my_key = self.get_node(category_index).category_key + for j in xrange(self.rowCount(category_index)): + tag_index = self.index(j, 0, category_index) + tag_item = self.get_node(tag_index) + if tag_item.type == TagTreeItem.CATEGORY: + if process_level(depth+1, tag_index, start_path): + return True + elif not key or strcmp(key, my_key) == 0: + if process_tag(depth+1, tag_index, tag_item, start_path): + return True + return False + + for i in xrange(self.rowCount(QModelIndex())): + if process_level(0, self.index(i, 0, QModelIndex()), start_path): + break + return self.path_found + + def find_category_node(self, key, parent=QModelIndex()): + ''' + Search for an category node (a top-level node) in the tags browser list + that matches the key (exact case-insensitive match). Returns the path to + the node. Paths are as in find_item_node. + ''' + if not key: + return None + + for i in xrange(self.rowCount(parent)): + idx = self.index(i, 0, parent) + node = self.get_node(idx) + if node.type == TagTreeItem.CATEGORY: + ckey = node.category_key + if strcmp(ckey, key) == 0: + return self.path_for_index(idx) + if len(node.children): + v = self.find_category_node(key, idx) + if v is not None: + return v + return None + + def set_boxed(self, idx): + tag_item = self.get_node(idx) + tag_item.boxed = True + self.dataChanged.emit(idx, idx) + + def clear_boxed(self): + ''' + Clear all boxes around items. + ''' + def process_tag(tag_index, tag_item): + if tag_item.boxed: + tag_item.boxed = False + self.dataChanged.emit(tag_index, tag_index) + for i,c in enumerate(tag_item.children): + process_tag(self.index(i, 0, tag_index), c) + + def process_level(category_index): + for j in xrange(self.rowCount(category_index)): + tag_index = self.index(j, 0, category_index) + tag_item = self.get_node(tag_index) + if tag_item.boxed: + tag_item.boxed = False + self.dataChanged.emit(tag_index, tag_index) + if tag_item.type == TagTreeItem.CATEGORY: + process_level(tag_index) + else: + process_tag(tag_index, tag_item) + + for i in xrange(self.rowCount(QModelIndex())): + process_level(self.index(i, 0, QModelIndex())) + + # }}} + diff --git a/src/calibre/gui2/tag_browser/ui.py b/src/calibre/gui2/tag_browser/ui.py new file mode 100644 index 0000000000..f7f724b118 --- /dev/null +++ b/src/calibre/gui2/tag_browser/ui.py @@ -0,0 +1,458 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2011, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +from functools import partial + +from PyQt4.Qt import (Qt, QIcon, QWidget, QHBoxLayout, QVBoxLayout, QShortcut, + QKeySequence, QToolButton, QString, QLabel, QFrame, QTimer, QComboBox, + QMenu, QPushButton) + +from calibre.gui2 import error_dialog, question_dialog +from calibre.gui2.widgets import HistoryLineEdit +from calibre.library.field_metadata import category_icon_map +from calibre.utils.icu import sort_key +from calibre.gui2.tag_browser.view import TagsView +from calibre.ebooks.metadata import title_sort +from calibre.gui2.dialogs.tag_categories import TagCategories +from calibre.gui2.dialogs.tag_list_editor import TagListEditor +from calibre.gui2.dialogs.edit_authors_dialog import EditAuthorsDialog + +class TagBrowserMixin(object): # {{{ + + def __init__(self, db): + self.library_view.model().count_changed_signal.connect(self.tags_view.recount) + self.tags_view.set_database(db, self.tag_match, self.sort_by) + self.tags_view.tags_marked.connect(self.search.set_search_string) + self.tags_view.tag_list_edit.connect(self.do_tags_list_edit) + self.tags_view.edit_user_category.connect(self.do_edit_user_categories) + self.tags_view.delete_user_category.connect(self.do_delete_user_category) + self.tags_view.del_item_from_user_cat.connect(self.do_del_item_from_user_cat) + self.tags_view.add_subcategory.connect(self.do_add_subcategory) + self.tags_view.add_item_to_user_cat.connect(self.do_add_item_to_user_cat) + self.tags_view.saved_search_edit.connect(self.do_saved_search_edit) + self.tags_view.rebuild_saved_searches.connect(self.do_rebuild_saved_searches) + self.tags_view.author_sort_edit.connect(self.do_author_sort_edit) + self.tags_view.tag_item_renamed.connect(self.do_tag_item_renamed) + self.tags_view.search_item_renamed.connect(self.saved_searches_changed) + self.tags_view.drag_drop_finished.connect(self.drag_drop_finished) + self.tags_view.restriction_error.connect(self.do_restriction_error, + type=Qt.QueuedConnection) + + for text, func, args, cat_name in ( + (_('Manage Authors'), + self.do_author_sort_edit, (self, None), 'authors'), + (_('Manage Series'), + self.do_tags_list_edit, (None, 'series'), 'series'), + (_('Manage Publishers'), + self.do_tags_list_edit, (None, 'publisher'), 'publisher'), + (_('Manage Tags'), + self.do_tags_list_edit, (None, 'tags'), 'tags'), + (_('Manage User Categories'), + self.do_edit_user_categories, (None,), 'user:'), + (_('Manage Saved Searches'), + self.do_saved_search_edit, (None,), 'search') + ): + self.manage_items_button.menu().addAction( + QIcon(I(category_icon_map[cat_name])), + text, partial(func, *args)) + + def do_restriction_error(self): + error_dialog(self.tags_view, _('Invalid search restriction'), + _('The current search restriction is invalid'), show=True) + + def do_add_subcategory(self, on_category_key, new_category_name=None): + ''' + Add a subcategory to the category 'on_category'. If new_category_name is + None, then a default name is shown and the user is offered the + opportunity to edit the name. + ''' + db = self.library_view.model().db + user_cats = db.prefs.get('user_categories', {}) + + # Ensure that the temporary name we will use is not already there + i = 0 + if new_category_name is not None: + new_name = new_category_name.replace('.', '') + else: + new_name = _('New Category').replace('.', '') + n = new_name + while True: + new_cat = on_category_key[1:] + '.' + n + if new_cat not in user_cats: + break + i += 1 + n = new_name + unicode(i) + # Add the new category + user_cats[new_cat] = [] + db.prefs.set('user_categories', user_cats) + self.tags_view.set_new_model() + m = self.tags_view.model() + idx = m.index_for_path(m.find_category_node('@' + new_cat)) + m.show_item_at_index(idx) + # Open the editor on the new item to rename it + if new_category_name is None: + self.tags_view.edit(idx) + + def do_edit_user_categories(self, on_category=None): + ''' + Open the user categories editor. + ''' + db = self.library_view.model().db + d = TagCategories(self, db, on_category) + if d.exec_() == d.Accepted: + db.prefs.set('user_categories', d.categories) + db.field_metadata.remove_user_categories() + for k in d.categories: + db.field_metadata.add_user_category('@' + k, k) + db.data.change_search_locations(db.field_metadata.get_search_terms()) + self.tags_view.set_new_model() + + def do_delete_user_category(self, category_name): + ''' + Delete the user category named category_name. Any leading '@' is removed + ''' + if category_name.startswith('@'): + category_name = category_name[1:] + db = self.library_view.model().db + user_cats = db.prefs.get('user_categories', {}) + cat_keys = sorted(user_cats.keys(), key=sort_key) + has_children = False + found = False + for k in cat_keys: + if k == category_name: + found = True + has_children = len(user_cats[k]) + elif k.startswith(category_name + '.'): + has_children = True + if not found: + return error_dialog(self.tags_view, _('Delete user category'), + _('%s is not a user category')%category_name, show=True) + if has_children: + if not question_dialog(self.tags_view, _('Delete user category'), + _('%s contains items. Do you really ' + 'want to delete it?')%category_name): + return + for k in cat_keys: + if k == category_name: + del user_cats[k] + elif k.startswith(category_name + '.'): + del user_cats[k] + db.prefs.set('user_categories', user_cats) + self.tags_view.set_new_model() + + def do_del_item_from_user_cat(self, user_cat, item_name, item_category): + ''' + Delete the item (item_name, item_category) from the user category with + key user_cat. Any leading '@' characters are removed + ''' + if user_cat.startswith('@'): + user_cat = user_cat[1:] + db = self.library_view.model().db + user_cats = db.prefs.get('user_categories', {}) + if user_cat not in user_cats: + error_dialog(self.tags_view, _('Remove category'), + _('User category %s does not exist')%user_cat, + show=True) + return + self.tags_view.model().delete_item_from_user_category(user_cat, + item_name, item_category) + self.tags_view.recount() + + def do_add_item_to_user_cat(self, dest_category, src_name, src_category): + ''' + Add the item src_name in src_category to the user category + dest_category. Any leading '@' is removed + ''' + db = self.library_view.model().db + user_cats = db.prefs.get('user_categories', {}) + + if dest_category and dest_category.startswith('@'): + dest_category = dest_category[1:] + + if dest_category not in user_cats: + return error_dialog(self.tags_view, _('Add to user category'), + _('A user category %s does not exist')%dest_category, show=True) + + # Now add the item to the destination user category + add_it = True + if src_category == 'news': + src_category = 'tags' + for tup in user_cats[dest_category]: + if src_name == tup[0] and src_category == tup[1]: + add_it = False + if add_it: + user_cats[dest_category].append([src_name, src_category, 0]) + db.prefs.set('user_categories', user_cats) + self.tags_view.recount() + + def do_tags_list_edit(self, tag, category): + ''' + Open the 'manage_X' dialog where X == category. If tag is not None, the + dialog will position the editor on that item. + ''' + db=self.library_view.model().db + if category == 'tags': + result = db.get_tags_with_ids() + key = sort_key + elif category == 'series': + result = db.get_series_with_ids() + key = lambda x:sort_key(title_sort(x)) + elif category == 'publisher': + result = db.get_publishers_with_ids() + key = sort_key + else: # should be a custom field + cc_label = None + if category in db.field_metadata: + cc_label = db.field_metadata[category]['label'] + result = db.get_custom_items_with_ids(label=cc_label) + else: + result = [] + key = sort_key + + d = TagListEditor(self, tag_to_match=tag, data=result, key=key) + d.exec_() + if d.result() == d.Accepted: + to_rename = d.to_rename # dict of new text to old id + to_delete = d.to_delete # list of ids + orig_name = d.original_names # dict of id: name + + rename_func = None + if category == 'tags': + rename_func = db.rename_tag + delete_func = db.delete_tag_using_id + elif category == 'series': + rename_func = db.rename_series + delete_func = db.delete_series_using_id + elif category == 'publisher': + rename_func = db.rename_publisher + delete_func = db.delete_publisher_using_id + else: + rename_func = partial(db.rename_custom_item, label=cc_label) + delete_func = partial(db.delete_custom_item_using_id, label=cc_label) + m = self.tags_view.model() + if rename_func: + for item in to_delete: + delete_func(item) + m.delete_item_from_all_user_categories(orig_name[item], category) + for old_id in to_rename: + rename_func(old_id, new_name=unicode(to_rename[old_id])) + m.rename_item_in_all_user_categories(orig_name[old_id], + category, unicode(to_rename[old_id])) + + # Clean up the library view + self.do_tag_item_renamed() + self.tags_view.recount() + + def do_tag_item_renamed(self): + # Clean up library view and search + # get information to redo the selection + rows = [r.row() for r in \ + self.library_view.selectionModel().selectedRows()] + m = self.library_view.model() + ids = [m.id(r) for r in rows] + + m.refresh(reset=False) + m.research() + self.library_view.select_rows(ids) + # refreshing the tags view happens at the emit()/call() site + + def do_author_sort_edit(self, parent, id, select_sort=True): + ''' + Open the manage authors dialog + ''' + db = self.library_view.model().db + editor = EditAuthorsDialog(parent, db, id, select_sort) + d = editor.exec_() + if d: + for (id, old_author, new_author, new_sort) in editor.result: + if old_author != new_author: + # The id might change if the new author already exists + id = db.rename_author(id, new_author) + db.set_sort_field_for_author(id, unicode(new_sort), + commit=False, notify=False) + db.commit() + self.library_view.model().refresh() + self.tags_view.recount() + + def drag_drop_finished(self, ids): + self.library_view.model().refresh_ids(ids) + +# }}} + +class TagBrowserWidget(QWidget): # {{{ + + def __init__(self, parent): + QWidget.__init__(self, parent) + self.parent = parent + self._layout = QVBoxLayout() + self.setLayout(self._layout) + self._layout.setContentsMargins(0,0,0,0) + + # Set up the find box & button + search_layout = QHBoxLayout() + self._layout.addLayout(search_layout) + self.item_search = HistoryLineEdit(parent) + try: + self.item_search.lineEdit().setPlaceholderText( + _('Find item in tag browser')) + except: + pass # Using Qt < 4.7 + self.item_search.setToolTip(_( + 'Search for items. This is a "contains" search; items containing the\n' + 'text anywhere in the name will be found. You can limit the search\n' + 'to particular categories using syntax similar to search. For example,\n' + 'tags:foo will find foo in any tag, but not in authors etc. Entering\n' + '*foo will filter all categories at once, showing only those items\n' + 'containing the text "foo"')) + search_layout.addWidget(self.item_search) + # Not sure if the shortcut should be translatable ... + sc = QShortcut(QKeySequence(_('ALT+f')), parent) + sc.activated.connect(self.set_focus_to_find_box) + + self.search_button = QToolButton() + self.search_button.setText(_('F&ind')) + self.search_button.setToolTip(_('Find the first/next matching item')) + search_layout.addWidget(self.search_button) + + self.expand_button = QToolButton() + self.expand_button.setText('-') + self.expand_button.setToolTip(_('Collapse all categories')) + search_layout.addWidget(self.expand_button) + search_layout.setStretch(0, 10) + search_layout.setStretch(1, 1) + search_layout.setStretch(2, 1) + + self.current_find_position = None + self.search_button.clicked.connect(self.find) + self.item_search.initialize('tag_browser_search') + self.item_search.lineEdit().returnPressed.connect(self.do_find) + self.item_search.lineEdit().textEdited.connect(self.find_text_changed) + self.item_search.activated[QString].connect(self.do_find) + self.item_search.completer().setCaseSensitivity(Qt.CaseSensitive) + + parent.tags_view = TagsView(parent) + self.tags_view = parent.tags_view + self.expand_button.clicked.connect(self.tags_view.collapseAll) + self._layout.addWidget(parent.tags_view) + + # Now the floating 'not found' box + l = QLabel(self.tags_view) + self.not_found_label = l + l.setFrameStyle(QFrame.StyledPanel) + l.setAutoFillBackground(True) + l.setText('

'+_('No More Matches.

Click Find again to go to first match')) + l.setAlignment(Qt.AlignVCenter) + l.setWordWrap(True) + l.resize(l.sizeHint()) + l.move(10,20) + l.setVisible(False) + self.not_found_label_timer = QTimer() + self.not_found_label_timer.setSingleShot(True) + self.not_found_label_timer.timeout.connect(self.not_found_label_timer_event, + type=Qt.QueuedConnection) + + parent.sort_by = QComboBox(parent) + # Must be in the same order as db2.CATEGORY_SORTS + for x in (_('Sort by name'), _('Sort by popularity'), + _('Sort by average rating')): + parent.sort_by.addItem(x) + parent.sort_by.setToolTip( + _('Set the sort order for entries in the Tag Browser')) + parent.sort_by.setStatusTip(parent.sort_by.toolTip()) + parent.sort_by.setCurrentIndex(0) + self._layout.addWidget(parent.sort_by) + + # Must be in the same order as db2.MATCH_TYPE + parent.tag_match = QComboBox(parent) + for x in (_('Match any'), _('Match all')): + parent.tag_match.addItem(x) + parent.tag_match.setCurrentIndex(0) + self._layout.addWidget(parent.tag_match) + parent.tag_match.setToolTip( + _('When selecting multiple entries in the Tag Browser ' + 'match any or all of them')) + parent.tag_match.setStatusTip(parent.tag_match.toolTip()) + + + l = parent.manage_items_button = QPushButton(self) + l.setStyleSheet('QPushButton {text-align: left; }') + l.setText(_('Manage authors, tags, etc')) + l.setToolTip(_('All of these category_managers are available by right-clicking ' + 'on items in the tag browser above')) + l.m = QMenu() + l.setMenu(l.m) + self._layout.addWidget(l) + + # self.leak_test_timer = QTimer(self) + # self.leak_test_timer.timeout.connect(self.test_for_leak) + # self.leak_test_timer.start(5000) + + def set_pane_is_visible(self, to_what): + self.tags_view.set_pane_is_visible(to_what) + + def find_text_changed(self, str): + self.current_find_position = None + + def set_focus_to_find_box(self): + self.item_search.setFocus() + self.item_search.lineEdit().selectAll() + + def do_find(self, str=None): + self.current_find_position = None + self.find() + + def find(self): + model = self.tags_view.model() + model.clear_boxed() + txt = unicode(self.item_search.currentText()).strip() + + if txt.startswith('*'): + self.tags_view.set_new_model(filter_categories_by=txt[1:]) + self.current_find_position = None + return + if model.get_filter_categories_by(): + self.tags_view.set_new_model(filter_categories_by=None) + self.current_find_position = None + model = self.tags_view.model() + + if not txt: + return + + self.item_search.lineEdit().blockSignals(True) + self.search_button.setFocus(True) + self.item_search.lineEdit().blockSignals(False) + + key = None + colon = txt.rfind(':') if len(txt) > 2 else 0 + if colon > 0: + key = self.parent.library_view.model().db.\ + field_metadata.search_term_to_field_key(txt[:colon]) + txt = txt[colon+1:] + + self.current_find_position = \ + model.find_item_node(key, txt, self.current_find_position) + if self.current_find_position: + model.show_item_at_path(self.current_find_position, box=True) + elif self.item_search.text(): + self.not_found_label.setVisible(True) + if self.tags_view.verticalScrollBar().isVisible(): + sbw = self.tags_view.verticalScrollBar().width() + else: + sbw = 0 + width = self.width() - 8 - sbw + height = self.not_found_label.heightForWidth(width) + 20 + self.not_found_label.resize(width, height) + self.not_found_label.move(4, 10) + self.not_found_label_timer.start(2000) + + def not_found_label_timer_event(self): + self.not_found_label.setVisible(False) + +# }}} + diff --git a/src/calibre/gui2/tag_browser/view.py b/src/calibre/gui2/tag_browser/view.py new file mode 100644 index 0000000000..4ff1227a6a --- /dev/null +++ b/src/calibre/gui2/tag_browser/view.py @@ -0,0 +1,588 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2011, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +import cPickle +from functools import partial +from itertools import izip + +from PyQt4.Qt import (QItemDelegate, Qt, QTreeView, pyqtSignal, QSize, QIcon, + QApplication, QMenu, QPoint, QModelIndex) + +from calibre.gui2.tag_browser.model import (TagTreeItem, TAG_SEARCH_STATES, + TagsModel) +from calibre.gui2 import config, gprefs +from calibre.utils.search_query_parser import saved_searches +from calibre.utils.icu import sort_key + +class TagDelegate(QItemDelegate): # {{{ + + def paint(self, painter, option, index): + item = index.data(Qt.UserRole).toPyObject() + if item.type != TagTreeItem.TAG: + QItemDelegate.paint(self, painter, option, index) + return + r = option.rect + model = self.parent().model() + icon = model.data(index, Qt.DecorationRole).toPyObject() + painter.save() + if item.tag.state != 0 or not config['show_avg_rating'] or \ + item.tag.avg_rating is None: + icon.paint(painter, r, Qt.AlignLeft) + else: + painter.setOpacity(0.3) + icon.paint(painter, r, Qt.AlignLeft) + painter.setOpacity(1) + rating = item.tag.avg_rating + painter.setClipRect(r.left(), r.bottom()-int(r.height()*(rating/5.0)), + r.width(), r.height()) + icon.paint(painter, r, Qt.AlignLeft) + painter.setClipRect(r) + + # Paint the text + if item.boxed: + painter.drawRoundedRect(r.adjusted(1,1,-1,-1), 5, 5) + r.setLeft(r.left()+r.height()+3) + painter.drawText(r, Qt.AlignLeft|Qt.AlignVCenter, + model.data(index, Qt.DisplayRole).toString()) + painter.restore() + + # }}} + +class TagsView(QTreeView): # {{{ + + refresh_required = pyqtSignal() + tags_marked = pyqtSignal(object) + edit_user_category = pyqtSignal(object) + delete_user_category = pyqtSignal(object) + del_item_from_user_cat = pyqtSignal(object, object, object) + add_item_to_user_cat = pyqtSignal(object, object, object) + add_subcategory = pyqtSignal(object) + tag_list_edit = pyqtSignal(object, object) + saved_search_edit = pyqtSignal(object) + rebuild_saved_searches = pyqtSignal() + author_sort_edit = pyqtSignal(object, object) + tag_item_renamed = pyqtSignal() + search_item_renamed = pyqtSignal() + drag_drop_finished = pyqtSignal(object) + restriction_error = pyqtSignal() + show_at_path = pyqtSignal() + + def __init__(self, parent=None): + QTreeView.__init__(self, parent=None) + self.tag_match = None + self.disable_recounting = False + self.setUniformRowHeights(True) + self.setCursor(Qt.PointingHandCursor) + self.setIconSize(QSize(30, 30)) + self.setTabKeyNavigation(True) + self.setAlternatingRowColors(True) + self.setAnimated(True) + self.setHeaderHidden(True) + self.setItemDelegate(TagDelegate(self)) + self.made_connections = False + self.setAcceptDrops(True) + self.setDragEnabled(True) + self.setDragDropMode(self.DragDrop) + self.setDropIndicatorShown(True) + self.setAutoExpandDelay(500) + self.pane_is_visible = False + self.search_icon = QIcon(I('search.png')) + self.user_category_icon = QIcon(I('tb_folder.png')) + self.delete_icon = QIcon(I('list_remove.png')) + self.rename_icon = QIcon(I('edit-undo.png')) + self.show_at_path.connect(self.show_item_at_path, + type=Qt.QueuedConnection) + + self._model = TagsModel(self) + self._model.search_item_renamed.connect(self.search_item_renamed) + self._model.refresh_required.connect(self.refresh_required, + type=Qt.QueuedConnection) + self._model.tag_item_renamed.connect(self.tag_item_renamed) + self._model.restriction_error.connect(self.restriction_error) + self._model.user_categories_edited.connect(self.user_categories_edited, + type=Qt.QueuedConnection) + self._model.drag_drop_finished.connect(self.drag_drop_finished) + + @property + def hidden_categories(self): + return self._model.hidden_categories + + @property + def db(self): + return self._model.db + + @property + def collapse_model(self): + return self._model.collapse_model + + def set_pane_is_visible(self, to_what): + pv = self.pane_is_visible + self.pane_is_visible = to_what + if to_what and not pv: + self.recount() + + def get_state(self): + state_map = {} + expanded_categories = [] + for row, category in enumerate(self._model.category_nodes): + if self.isExpanded(self._model.index(row, 0, QModelIndex())): + expanded_categories.append(category.py_name) + states = [c.tag.state for c in category.child_tags()] + names = [(c.tag.name, c.tag.category) for c in category.child_tags()] + state_map[category.py_name] = dict(izip(names, states)) + return expanded_categories, state_map + + def reread_collapse_parameters(self): + self._model.reread_collapse_parameters(self.get_state()[1]) + + def set_database(self, db, tag_match, sort_by): + self._model.set_database(db) + + self.pane_is_visible = True # because TagsModel.set_database did a recount + self.sort_by = sort_by + self.tag_match = tag_match + self.setModel(self._model) + self.setContextMenuPolicy(Qt.CustomContextMenu) + pop = config['sort_tags_by'] + self.sort_by.setCurrentIndex(self.db.CATEGORY_SORTS.index(pop)) + try: + match_pop = self.db.MATCH_TYPE.index(config['match_tags_type']) + except ValueError: + match_pop = 0 + self.tag_match.setCurrentIndex(match_pop) + if not self.made_connections: + self.clicked.connect(self.toggle) + self.customContextMenuRequested.connect(self.show_context_menu) + self.refresh_required.connect(self.recount, type=Qt.QueuedConnection) + self.sort_by.currentIndexChanged.connect(self.sort_changed) + self.tag_match.currentIndexChanged.connect(self.match_changed) + self.made_connections = True + self.refresh_signal_processed = True + db.add_listener(self.database_changed) + self.expanded.connect(self.item_expanded) + + def database_changed(self, event, ids): + if self.refresh_signal_processed: + self.refresh_signal_processed = False + self.refresh_required.emit() + + def user_categories_edited(self, user_cats, nkey): + state_map = self.get_state()[1] + self.db.prefs.set('user_categories', user_cats) + self._model.rebuild_node_tree(state_map=state_map) + self.show_at_path.emit('@'+nkey) + + @property + def match_all(self): + return self.tag_match and self.tag_match.currentIndex() > 0 + + def sort_changed(self, pop): + config.set('sort_tags_by', self.db.CATEGORY_SORTS[pop]) + self.recount() + + def match_changed(self, pop): + try: + config.set('match_tags_type', self.db.MATCH_TYPE[pop]) + except: + pass + + def set_search_restriction(self, s): + s = s if s else None + self._model.set_search_restriction(s) + + def mouseReleaseEvent(self, event): + # Swallow everything except leftButton so context menus work correctly + if event.button() == Qt.LeftButton: + QTreeView.mouseReleaseEvent(self, event) + + def mouseDoubleClickEvent(self, event): + # swallow these to avoid toggling and editing at the same time + pass + + @property + def search_string(self): + tokens = self._model.tokens() + joiner = ' and ' if self.match_all else ' or ' + return joiner.join(tokens) + + def toggle(self, index): + self._toggle(index, None) + + def _toggle(self, index, set_to): + ''' + set_to: if None, advance the state. Otherwise must be one of the values + in TAG_SEARCH_STATES + ''' + modifiers = int(QApplication.keyboardModifiers()) + exclusive = modifiers not in (Qt.CTRL, Qt.SHIFT) + if self._model.toggle(index, exclusive, set_to=set_to): + self.tags_marked.emit(self.search_string) + + def conditional_clear(self, search_string): + if search_string != self.search_string: + self.clear() + + def context_menu_handler(self, action=None, category=None, + key=None, index=None, search_state=None): + if not action: + return + try: + if action == 'edit_item': + self.edit(index) + return + if action == 'open_editor': + self.tag_list_edit.emit(category, key) + return + if action == 'manage_categories': + self.edit_user_category.emit(category) + return + if action == 'search': + self._toggle(index, set_to=search_state) + return + if action == 'add_to_category': + tag = index.tag + if len(index.children) > 0: + for c in index.children: + self.add_item_to_user_cat.emit(category, c.tag.original_name, + c.tag.category) + self.add_item_to_user_cat.emit(category, tag.original_name, + tag.category) + return + if action == 'add_subcategory': + self.add_subcategory.emit(key) + return + if action == 'search_category': + self._toggle(index, set_to=search_state) + return + if action == 'delete_user_category': + self.delete_user_category.emit(key) + return + if action == 'delete_search': + saved_searches().delete(key) + self.rebuild_saved_searches.emit() + return + if action == 'delete_item_from_user_category': + tag = index.tag + if len(index.children) > 0: + for c in index.children: + self.del_item_from_user_cat.emit(key, c.tag.original_name, + c.tag.category) + self.del_item_from_user_cat.emit(key, tag.original_name, tag.category) + return + if action == 'manage_searches': + self.saved_search_edit.emit(category) + return + if action == 'edit_author_sort': + self.author_sort_edit.emit(self, index) + return + + reset_filter_categories = True + if action == 'hide': + self.hidden_categories.add(category) + elif action == 'show': + self.hidden_categories.discard(category) + elif action == 'categorization': + changed = self.collapse_model != category + self._model.collapse_model = category + if changed: + reset_filter_categories = False + gprefs['tags_browser_partition_method'] = category + elif action == 'defaults': + self.hidden_categories.clear() + self.db.prefs.set('tag_browser_hidden_categories', list(self.hidden_categories)) + if reset_filter_categories: + self._model.filter_categories_by = None + self._model.rebuild_node_tree() + except: + return + + def show_context_menu(self, point): + def display_name( tag): + if tag.category == 'search': + n = tag.name + if len(n) > 45: + n = n[:45] + '...' + return "'" + n + "'" + return tag.name + + index = self.indexAt(point) + self.context_menu = QMenu(self) + + if index.isValid(): + item = index.data(Qt.UserRole).toPyObject() + tag = None + + if item.type == TagTreeItem.TAG: + tag_item = item + tag = item.tag + while item.type != TagTreeItem.CATEGORY: + item = item.parent + + if item.type == TagTreeItem.CATEGORY: + if not item.category_key.startswith('@'): + while item.parent != self._model.root_item: + item = item.parent + category = unicode(item.name.toString()) + key = item.category_key + # Verify that we are working with a field that we know something about + if key not in self.db.field_metadata: + return True + + # Did the user click on a leaf node? + if tag: + # If the user right-clicked on an editable item, then offer + # the possibility of renaming that item. + if tag.is_editable: + # Add the 'rename' items + self.context_menu.addAction(self.rename_icon, + _('Rename %s')%display_name(tag), + partial(self.context_menu_handler, action='edit_item', + index=index)) + if key == 'authors': + self.context_menu.addAction(_('Edit sort for %s')%display_name(tag), + partial(self.context_menu_handler, + action='edit_author_sort', index=tag.id)) + + # is_editable is also overloaded to mean 'can be added + # to a user category' + m = self.context_menu.addMenu(self.user_category_icon, + _('Add %s to user category')%display_name(tag)) + nt = self.model().category_node_tree + def add_node_tree(tree_dict, m, path): + p = path[:] + for k in sorted(tree_dict.keys(), key=sort_key): + p.append(k) + n = k[1:] if k.startswith('@') else k + m.addAction(self.user_category_icon, n, + partial(self.context_menu_handler, + 'add_to_category', + category='.'.join(p), index=tag_item)) + if len(tree_dict[k]): + tm = m.addMenu(self.user_category_icon, + _('Children of %s')%n) + add_node_tree(tree_dict[k], tm, p) + p.pop() + add_node_tree(nt, m, []) + elif key == 'search': + self.context_menu.addAction(self.rename_icon, + _('Rename %s')%display_name(tag), + partial(self.context_menu_handler, action='edit_item', + index=index)) + self.context_menu.addAction(self.delete_icon, + _('Delete search %s')%display_name(tag), + partial(self.context_menu_handler, + action='delete_search', key=tag.name)) + if key.startswith('@') and not item.is_gst: + self.context_menu.addAction(self.user_category_icon, + _('Remove %s from category %s')% + (display_name(tag), item.py_name), + partial(self.context_menu_handler, + action='delete_item_from_user_category', + key = key, index = tag_item)) + # Add the search for value items. All leaf nodes are searchable + self.context_menu.addAction(self.search_icon, + _('Search for %s')%display_name(tag), + partial(self.context_menu_handler, action='search', + search_state=TAG_SEARCH_STATES['mark_plus'], + index=index)) + self.context_menu.addAction(self.search_icon, + _('Search for everything but %s')%display_name(tag), + partial(self.context_menu_handler, action='search', + search_state=TAG_SEARCH_STATES['mark_minus'], + index=index)) + self.context_menu.addSeparator() + elif key.startswith('@') and not item.is_gst: + if item.can_be_edited: + self.context_menu.addAction(self.rename_icon, + _('Rename %s')%item.py_name, + partial(self.context_menu_handler, action='edit_item', + index=index)) + self.context_menu.addAction(self.user_category_icon, + _('Add sub-category to %s')%item.py_name, + partial(self.context_menu_handler, + action='add_subcategory', key=key)) + self.context_menu.addAction(self.delete_icon, + _('Delete user category %s')%item.py_name, + partial(self.context_menu_handler, + action='delete_user_category', key=key)) + self.context_menu.addSeparator() + # Hide/Show/Restore categories + self.context_menu.addAction(_('Hide category %s') % category, + partial(self.context_menu_handler, action='hide', + category=key)) + if self.hidden_categories: + m = self.context_menu.addMenu(_('Show category')) + for col in sorted(self.hidden_categories, + key=lambda x: sort_key(self.db.field_metadata[x]['name'])): + m.addAction(self.db.field_metadata[col]['name'], + partial(self.context_menu_handler, action='show', category=col)) + + # search by category. Some categories are not searchable, such + # as search and news + if item.tag.is_searchable: + self.context_menu.addAction(self.search_icon, + _('Search for books in category %s')%category, + partial(self.context_menu_handler, + action='search_category', + index=self._model.createIndex(item.row(), 0, item), + search_state=TAG_SEARCH_STATES['mark_plus'])) + self.context_menu.addAction(self.search_icon, + _('Search for books not in category %s')%category, + partial(self.context_menu_handler, + action='search_category', + index=self._model.createIndex(item.row(), 0, item), + search_state=TAG_SEARCH_STATES['mark_minus'])) + # Offer specific editors for tags/series/publishers/saved searches + self.context_menu.addSeparator() + if key in ['tags', 'publisher', 'series'] or \ + self.db.field_metadata[key]['is_custom']: + self.context_menu.addAction(_('Manage %s')%category, + partial(self.context_menu_handler, action='open_editor', + category=tag.original_name if tag else None, + key=key)) + elif key == 'authors': + self.context_menu.addAction(_('Manage %s')%category, + partial(self.context_menu_handler, action='edit_author_sort')) + elif key == 'search': + self.context_menu.addAction(_('Manage Saved Searches'), + partial(self.context_menu_handler, action='manage_searches', + category=tag.name if tag else None)) + + # Always show the user categories editor + self.context_menu.addSeparator() + if key.startswith('@') and \ + key[1:] in self.db.prefs.get('user_categories', {}).keys(): + self.context_menu.addAction(_('Manage User Categories'), + partial(self.context_menu_handler, action='manage_categories', + category=key[1:])) + else: + self.context_menu.addAction(_('Manage User Categories'), + partial(self.context_menu_handler, action='manage_categories', + category=None)) + + if self.hidden_categories: + if not self.context_menu.isEmpty(): + self.context_menu.addSeparator() + self.context_menu.addAction(_('Show all categories'), + partial(self.context_menu_handler, action='defaults')) + + m = self.context_menu.addMenu(_('Change sub-categorization scheme')) + da = m.addAction('Disable', + partial(self.context_menu_handler, action='categorization', category='disable')) + fla = m.addAction('By first letter', + partial(self.context_menu_handler, action='categorization', category='first letter')) + pa = m.addAction('Partition', + partial(self.context_menu_handler, action='categorization', category='partition')) + if self.collapse_model == 'disable': + da.setCheckable(True) + da.setChecked(True) + elif self.collapse_model == 'first letter': + fla.setCheckable(True) + fla.setChecked(True) + else: + pa.setCheckable(True) + pa.setChecked(True) + + if not self.context_menu.isEmpty(): + self.context_menu.popup(self.mapToGlobal(point)) + return True + + def dragMoveEvent(self, event): + QTreeView.dragMoveEvent(self, event) + self.setDropIndicatorShown(False) + index = self.indexAt(event.pos()) + if not index.isValid(): + return + src_is_tb = event.mimeData().hasFormat('application/calibre+from_tag_browser') + item = index.data(Qt.UserRole).toPyObject() + flags = self._model.flags(index) + if item.type == TagTreeItem.TAG and flags & Qt.ItemIsDropEnabled: + self.setDropIndicatorShown(not src_is_tb) + return + if item.type == TagTreeItem.CATEGORY and not item.is_gst: + fm_dest = self.db.metadata_for_field(item.category_key) + if fm_dest['kind'] == 'user': + if src_is_tb: + if event.dropAction() == Qt.MoveAction: + data = str(event.mimeData().data('application/calibre+from_tag_browser')) + src = cPickle.loads(data) + for s in src: + if s[0] == TagTreeItem.TAG and \ + (not s[1].startswith('@') or s[2]): + return + self.setDropIndicatorShown(True) + return + md = event.mimeData() + if hasattr(md, 'column_name'): + fm_src = self.db.metadata_for_field(md.column_name) + if md.column_name in ['authors', 'publisher', 'series'] or \ + (fm_src['is_custom'] and ( + (fm_src['datatype'] in ['series', 'text', 'enumeration'] and + not fm_src['is_multiple']) or + (fm_src['datatype'] == 'composite' and + fm_src['display'].get('make_category', False)))): + self.setDropIndicatorShown(True) + + def clear(self): + if self.model(): + self.model().clear_state() + + def is_visible(self, idx): + item = idx.data(Qt.UserRole).toPyObject() + if getattr(item, 'type', None) == TagTreeItem.TAG: + idx = idx.parent() + return self.isExpanded(idx) + + def recount(self, *args): + ''' + Rebuild the category tree, expand any categories that were expanded, + reset the search states, and reselect the current node. + ''' + if self.disable_recounting or not self.pane_is_visible: + return + self.refresh_signal_processed = True + ci = self.currentIndex() + if not ci.isValid(): + ci = self.indexAt(QPoint(10, 10)) + path = self.model().path_for_index(ci) if self.is_visible(ci) else None + expanded_categories, state_map = self.get_state() + self._model.rebuild_node_tree(state_map=state_map) + for category in expanded_categories: + self.expand(self._model.index_for_category(category)) + self.show_item_at_path(path) + + def show_item_at_path(self, path, box=False, + position=QTreeView.PositionAtCenter): + ''' + Scroll the browser and open categories to show the item referenced by + path. If possible, the item is placed in the center. If box=True, a + box is drawn around the item. + ''' + if path: + self.show_item_at_index(self._model.index_for_path(path), box=box, + position=position) + + def show_item_at_index(self, idx, box=False, + position=QTreeView.PositionAtCenter): + if idx.isValid(): + self.setCurrentIndex(idx) + self.scrollTo(idx, position) + self.setCurrentIndex(idx) + if box: + self._model.set_boxed(idx) + + def item_expanded(self, idx): + ''' + Called by the expanded signal + ''' + self.setCurrentIndex(idx) + + # }}} + + diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py deleted file mode 100644 index 21029da68c..0000000000 --- a/src/calibre/gui2/tag_view.py +++ /dev/null @@ -1,2311 +0,0 @@ -#!/usr/bin/env python -__license__ = 'GPL v3' -__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net' -__docformat__ = 'restructuredtext en' - -''' -Browsing book collection by tags. -''' - -import traceback, copy, cPickle - -from itertools import izip, repeat -from functools import partial - -from PyQt4.Qt import (Qt, QTreeView, QApplication, pyqtSignal, QFont, QSize, - QIcon, QPoint, QVBoxLayout, QHBoxLayout, QComboBox, QTimer, - QAbstractItemModel, QVariant, QModelIndex, QMenu, QFrame, - QWidget, QItemDelegate, QString, QLabel, QPushButton, - QShortcut, QKeySequence, SIGNAL, QMimeData, QToolButton) - -from calibre.ebooks.metadata import title_sort -from calibre.gui2 import config, NONE, gprefs -from calibre.library.field_metadata import TagsIcons, category_icon_map -from calibre.library.database2 import Tag -from calibre.utils.config import tweaks -from calibre.utils.icu import sort_key, lower, strcmp -from calibre.utils.search_query_parser import saved_searches -from calibre.utils.formatter import eval_formatter -from calibre.gui2 import error_dialog, question_dialog -from calibre.gui2.dialogs.confirm_delete import confirm -from calibre.gui2.dialogs.tag_categories import TagCategories -from calibre.gui2.dialogs.tag_list_editor import TagListEditor -from calibre.gui2.dialogs.edit_authors_dialog import EditAuthorsDialog -from calibre.gui2.widgets import HistoryLineEdit - -class TagDelegate(QItemDelegate): # {{{ - - def paint(self, painter, option, index): - item = index.internalPointer() - if item.type != TagTreeItem.TAG: - QItemDelegate.paint(self, painter, option, index) - return - r = option.rect - model = self.parent().model() - icon = model.data(index, Qt.DecorationRole).toPyObject() - painter.save() - if item.tag.state != 0 or not config['show_avg_rating'] or \ - item.tag.avg_rating is None: - icon.paint(painter, r, Qt.AlignLeft) - else: - painter.setOpacity(0.3) - icon.paint(painter, r, Qt.AlignLeft) - painter.setOpacity(1) - rating = item.tag.avg_rating - painter.setClipRect(r.left(), r.bottom()-int(r.height()*(rating/5.0)), - r.width(), r.height()) - icon.paint(painter, r, Qt.AlignLeft) - painter.setClipRect(r) - - # Paint the text - if item.boxed: - painter.drawRoundedRect(r.adjusted(1,1,-1,-1), 5, 5) - r.setLeft(r.left()+r.height()+3) - painter.drawText(r, Qt.AlignLeft|Qt.AlignVCenter, - model.data(index, Qt.DisplayRole).toString()) - painter.restore() - - # }}} - -TAG_SEARCH_STATES = {'clear': 0, 'mark_plus': 1, 'mark_plusplus': 2, - 'mark_minus': 3, 'mark_minusminus': 4} - -class TagsView(QTreeView): # {{{ - - refresh_required = pyqtSignal() - tags_marked = pyqtSignal(object) - edit_user_category = pyqtSignal(object) - delete_user_category = pyqtSignal(object) - del_item_from_user_cat = pyqtSignal(object, object, object) - add_item_to_user_cat = pyqtSignal(object, object, object) - add_subcategory = pyqtSignal(object) - tag_list_edit = pyqtSignal(object, object) - saved_search_edit = pyqtSignal(object) - rebuild_saved_searches = pyqtSignal() - author_sort_edit = pyqtSignal(object, object) - tag_item_renamed = pyqtSignal() - search_item_renamed = pyqtSignal() - drag_drop_finished = pyqtSignal(object) - restriction_error = pyqtSignal() - - def __init__(self, parent=None): - QTreeView.__init__(self, parent=None) - self.tag_match = None - self.disable_recounting = False - self.setUniformRowHeights(True) - self.setCursor(Qt.PointingHandCursor) - self.setIconSize(QSize(30, 30)) - self.setTabKeyNavigation(True) - self.setAlternatingRowColors(True) - self.setAnimated(True) - self.setHeaderHidden(True) - self.setItemDelegate(TagDelegate(self)) - self.made_connections = False - self.setAcceptDrops(True) - self.setDragEnabled(True) - self.setDragDropMode(self.DragDrop) - self.setDropIndicatorShown(True) - self.setAutoExpandDelay(500) - self.pane_is_visible = False - if gprefs['tags_browser_collapse_at'] == 0: - self.collapse_model = 'disable' - else: - self.collapse_model = gprefs['tags_browser_partition_method'] - self.search_icon = QIcon(I('search.png')) - self.user_category_icon = QIcon(I('tb_folder.png')) - self.delete_icon = QIcon(I('list_remove.png')) - self.rename_icon = QIcon(I('edit-undo.png')) - - def set_pane_is_visible(self, to_what): - pv = self.pane_is_visible - self.pane_is_visible = to_what - if to_what and not pv: - self.recount() - - def reread_collapse_parameters(self): - if gprefs['tags_browser_collapse_at'] == 0: - self.collapse_model = 'disable' - else: - self.collapse_model = gprefs['tags_browser_partition_method'] - self.set_new_model(self._model.get_filter_categories_by()) - - def set_database(self, db, tag_match, sort_by): - hidden_cats = db.prefs.get('tag_browser_hidden_categories', None) - self.hidden_categories = [] - # migrate from config to db prefs - if hidden_cats is None: - hidden_cats = config['tag_browser_hidden_categories'] - # strip out any non-existence field keys - for cat in hidden_cats: - if cat in db.field_metadata: - self.hidden_categories.append(cat) - db.prefs.set('tag_browser_hidden_categories', list(self.hidden_categories)) - self.hidden_categories = set(self.hidden_categories) - - old = getattr(self, '_model', None) - if old is not None: - old.break_cycles() - self._model = TagsModel(db, parent=self, - hidden_categories=self.hidden_categories, - search_restriction=None, - drag_drop_finished=self.drag_drop_finished, - collapse_model=self.collapse_model, - state_map={}) - self.pane_is_visible = True # because TagsModel.init did a recount - self.sort_by = sort_by - self.tag_match = tag_match - self.db = db - self.search_restriction = None - self.setModel(self._model) - self.setContextMenuPolicy(Qt.CustomContextMenu) - pop = config['sort_tags_by'] - self.sort_by.setCurrentIndex(self.db.CATEGORY_SORTS.index(pop)) - try: - match_pop = self.db.MATCH_TYPE.index(config['match_tags_type']) - except ValueError: - match_pop = 0 - self.tag_match.setCurrentIndex(match_pop) - if not self.made_connections: - self.clicked.connect(self.toggle) - self.customContextMenuRequested.connect(self.show_context_menu) - self.refresh_required.connect(self.recount, type=Qt.QueuedConnection) - self.sort_by.currentIndexChanged.connect(self.sort_changed) - self.tag_match.currentIndexChanged.connect(self.match_changed) - self.made_connections = True - self.refresh_signal_processed = True - db.add_listener(self.database_changed) - self.expanded.connect(self.item_expanded) - - def database_changed(self, event, ids): - if self.refresh_signal_processed: - self.refresh_signal_processed = False - self.refresh_required.emit() - - @property - def match_all(self): - return self.tag_match and self.tag_match.currentIndex() > 0 - - def sort_changed(self, pop): - config.set('sort_tags_by', self.db.CATEGORY_SORTS[pop]) - self.recount() - - def match_changed(self, pop): - try: - config.set('match_tags_type', self.db.MATCH_TYPE[pop]) - except: - pass - - def set_search_restriction(self, s): - if s: - self.search_restriction = s - else: - self.search_restriction = None - self.set_new_model() - - def mouseReleaseEvent(self, event): - # Swallow everything except leftButton so context menus work correctly - if event.button() == Qt.LeftButton: - QTreeView.mouseReleaseEvent(self, event) - - def mouseDoubleClickEvent(self, event): - # swallow these to avoid toggling and editing at the same time - pass - - @property - def search_string(self): - tokens = self._model.tokens() - joiner = ' and ' if self.match_all else ' or ' - return joiner.join(tokens) - - def toggle(self, index): - self._toggle(index, None) - - def _toggle(self, index, set_to): - ''' - set_to: if None, advance the state. Otherwise must be one of the values - in TAG_SEARCH_STATES - ''' - modifiers = int(QApplication.keyboardModifiers()) - exclusive = modifiers not in (Qt.CTRL, Qt.SHIFT) - if self._model.toggle(index, exclusive, set_to=set_to): - self.tags_marked.emit(self.search_string) - - def conditional_clear(self, search_string): - if search_string != self.search_string: - self.clear() - - def context_menu_handler(self, action=None, category=None, - key=None, index=None, search_state=None): - if not action: - return - try: - if action == 'edit_item': - self.edit(index) - return - if action == 'open_editor': - self.tag_list_edit.emit(category, key) - return - if action == 'manage_categories': - self.edit_user_category.emit(category) - return - if action == 'search': - self._toggle(index, set_to=search_state) - return - if action == 'add_to_category': - tag = index.tag - if len(index.children) > 0: - for c in index.children: - self.add_item_to_user_cat.emit(category, c.tag.original_name, - c.tag.category) - self.add_item_to_user_cat.emit(category, tag.original_name, - tag.category) - return - if action == 'add_subcategory': - self.add_subcategory.emit(key) - return - if action == 'search_category': - self._toggle(index, set_to=search_state) - return - if action == 'delete_user_category': - self.delete_user_category.emit(key) - return - if action == 'delete_search': - saved_searches().delete(key) - self.rebuild_saved_searches.emit() - return - if action == 'delete_item_from_user_category': - tag = index.tag - if len(index.children) > 0: - for c in index.children: - self.del_item_from_user_cat.emit(key, c.tag.original_name, - c.tag.category) - self.del_item_from_user_cat.emit(key, tag.original_name, tag.category) - return - if action == 'manage_searches': - self.saved_search_edit.emit(category) - return - if action == 'edit_author_sort': - self.author_sort_edit.emit(self, index) - return - - if action == 'hide': - self.hidden_categories.add(category) - elif action == 'show': - self.hidden_categories.discard(category) - elif action == 'categorization': - changed = self.collapse_model != category - self.collapse_model = category - if changed: - self.set_new_model(self._model.get_filter_categories_by()) - gprefs['tags_browser_partition_method'] = category - elif action == 'defaults': - self.hidden_categories.clear() - self.db.prefs.set('tag_browser_hidden_categories', list(self.hidden_categories)) - self.set_new_model() - except: - return - - def show_context_menu(self, point): - def display_name( tag): - if tag.category == 'search': - n = tag.name - if len(n) > 45: - n = n[:45] + '...' - return "'" + n + "'" - return tag.name - - index = self.indexAt(point) - self.context_menu = QMenu(self) - - if index.isValid(): - item = index.internalPointer() - tag = None - - if item.type == TagTreeItem.TAG: - tag_item = item - tag = item.tag - while item.type != TagTreeItem.CATEGORY: - item = item.parent - - if item.type == TagTreeItem.CATEGORY: - if not item.category_key.startswith('@'): - while item.parent != self._model.root_item: - item = item.parent - category = unicode(item.name.toString()) - key = item.category_key - # Verify that we are working with a field that we know something about - if key not in self.db.field_metadata: - return True - - # Did the user click on a leaf node? - if tag: - # If the user right-clicked on an editable item, then offer - # the possibility of renaming that item. - if tag.is_editable: - # Add the 'rename' items - self.context_menu.addAction(self.rename_icon, - _('Rename %s')%display_name(tag), - partial(self.context_menu_handler, action='edit_item', - index=index)) - if key == 'authors': - self.context_menu.addAction(_('Edit sort for %s')%display_name(tag), - partial(self.context_menu_handler, - action='edit_author_sort', index=tag.id)) - - # is_editable is also overloaded to mean 'can be added - # to a user category' - m = self.context_menu.addMenu(self.user_category_icon, - _('Add %s to user category')%display_name(tag)) - nt = self.model().category_node_tree - def add_node_tree(tree_dict, m, path): - p = path[:] - for k in sorted(tree_dict.keys(), key=sort_key): - p.append(k) - n = k[1:] if k.startswith('@') else k - m.addAction(self.user_category_icon, n, - partial(self.context_menu_handler, - 'add_to_category', - category='.'.join(p), index=tag_item)) - if len(tree_dict[k]): - tm = m.addMenu(self.user_category_icon, - _('Children of %s')%n) - add_node_tree(tree_dict[k], tm, p) - p.pop() - add_node_tree(nt, m, []) - elif key == 'search': - self.context_menu.addAction(self.rename_icon, - _('Rename %s')%display_name(tag), - partial(self.context_menu_handler, action='edit_item', - index=index)) - self.context_menu.addAction(self.delete_icon, - _('Delete search %s')%display_name(tag), - partial(self.context_menu_handler, - action='delete_search', key=tag.name)) - if key.startswith('@') and not item.is_gst: - self.context_menu.addAction(self.user_category_icon, - _('Remove %s from category %s')% - (display_name(tag), item.py_name), - partial(self.context_menu_handler, - action='delete_item_from_user_category', - key = key, index = tag_item)) - # Add the search for value items. All leaf nodes are searchable - self.context_menu.addAction(self.search_icon, - _('Search for %s')%display_name(tag), - partial(self.context_menu_handler, action='search', - search_state=TAG_SEARCH_STATES['mark_plus'], - index=index)) - self.context_menu.addAction(self.search_icon, - _('Search for everything but %s')%display_name(tag), - partial(self.context_menu_handler, action='search', - search_state=TAG_SEARCH_STATES['mark_minus'], - index=index)) - self.context_menu.addSeparator() - elif key.startswith('@') and not item.is_gst: - if item.can_be_edited: - self.context_menu.addAction(self.rename_icon, - _('Rename %s')%item.py_name, - partial(self.context_menu_handler, action='edit_item', - index=index)) - self.context_menu.addAction(self.user_category_icon, - _('Add sub-category to %s')%item.py_name, - partial(self.context_menu_handler, - action='add_subcategory', key=key)) - self.context_menu.addAction(self.delete_icon, - _('Delete user category %s')%item.py_name, - partial(self.context_menu_handler, - action='delete_user_category', key=key)) - self.context_menu.addSeparator() - # Hide/Show/Restore categories - self.context_menu.addAction(_('Hide category %s') % category, - partial(self.context_menu_handler, action='hide', - category=key)) - if self.hidden_categories: - m = self.context_menu.addMenu(_('Show category')) - for col in sorted(self.hidden_categories, - key=lambda x: sort_key(self.db.field_metadata[x]['name'])): - m.addAction(self.db.field_metadata[col]['name'], - partial(self.context_menu_handler, action='show', category=col)) - - # search by category. Some categories are not searchable, such - # as search and news - if item.tag.is_searchable: - self.context_menu.addAction(self.search_icon, - _('Search for books in category %s')%category, - partial(self.context_menu_handler, - action='search_category', - index=self._model.createIndex(item.row(), 0, item), - search_state=TAG_SEARCH_STATES['mark_plus'])) - self.context_menu.addAction(self.search_icon, - _('Search for books not in category %s')%category, - partial(self.context_menu_handler, - action='search_category', - index=self._model.createIndex(item.row(), 0, item), - search_state=TAG_SEARCH_STATES['mark_minus'])) - # Offer specific editors for tags/series/publishers/saved searches - self.context_menu.addSeparator() - if key in ['tags', 'publisher', 'series'] or \ - self.db.field_metadata[key]['is_custom']: - self.context_menu.addAction(_('Manage %s')%category, - partial(self.context_menu_handler, action='open_editor', - category=tag.original_name if tag else None, - key=key)) - elif key == 'authors': - self.context_menu.addAction(_('Manage %s')%category, - partial(self.context_menu_handler, action='edit_author_sort')) - elif key == 'search': - self.context_menu.addAction(_('Manage Saved Searches'), - partial(self.context_menu_handler, action='manage_searches', - category=tag.name if tag else None)) - - # Always show the user categories editor - self.context_menu.addSeparator() - if key.startswith('@') and \ - key[1:] in self.db.prefs.get('user_categories', {}).keys(): - self.context_menu.addAction(_('Manage User Categories'), - partial(self.context_menu_handler, action='manage_categories', - category=key[1:])) - else: - self.context_menu.addAction(_('Manage User Categories'), - partial(self.context_menu_handler, action='manage_categories', - category=None)) - - if self.hidden_categories: - if not self.context_menu.isEmpty(): - self.context_menu.addSeparator() - self.context_menu.addAction(_('Show all categories'), - partial(self.context_menu_handler, action='defaults')) - - m = self.context_menu.addMenu(_('Change sub-categorization scheme')) - da = m.addAction('Disable', - partial(self.context_menu_handler, action='categorization', category='disable')) - fla = m.addAction('By first letter', - partial(self.context_menu_handler, action='categorization', category='first letter')) - pa = m.addAction('Partition', - partial(self.context_menu_handler, action='categorization', category='partition')) - if self.collapse_model == 'disable': - da.setCheckable(True) - da.setChecked(True) - elif self.collapse_model == 'first letter': - fla.setCheckable(True) - fla.setChecked(True) - else: - pa.setCheckable(True) - pa.setChecked(True) - - if not self.context_menu.isEmpty(): - self.context_menu.popup(self.mapToGlobal(point)) - return True - - def dragMoveEvent(self, event): - QTreeView.dragMoveEvent(self, event) - self.setDropIndicatorShown(False) - index = self.indexAt(event.pos()) - if not index.isValid(): - return - src_is_tb = event.mimeData().hasFormat('application/calibre+from_tag_browser') - item = index.internalPointer() - flags = self._model.flags(index) - if item.type == TagTreeItem.TAG and flags & Qt.ItemIsDropEnabled: - self.setDropIndicatorShown(not src_is_tb) - return - if item.type == TagTreeItem.CATEGORY and not item.is_gst: - fm_dest = self.db.metadata_for_field(item.category_key) - if fm_dest['kind'] == 'user': - if src_is_tb: - if event.dropAction() == Qt.MoveAction: - data = str(event.mimeData().data('application/calibre+from_tag_browser')) - src = cPickle.loads(data) - for s in src: - if s[0] == TagTreeItem.TAG and \ - (not s[1].startswith('@') or s[2]): - return - self.setDropIndicatorShown(True) - return - md = event.mimeData() - if hasattr(md, 'column_name'): - fm_src = self.db.metadata_for_field(md.column_name) - if md.column_name in ['authors', 'publisher', 'series'] or \ - (fm_src['is_custom'] and ( - (fm_src['datatype'] in ['series', 'text', 'enumeration'] and - not fm_src['is_multiple']) or - (fm_src['datatype'] == 'composite' and - fm_src['display'].get('make_category', False)))): - self.setDropIndicatorShown(True) - - def clear(self): - if self.model(): - self.model().clear_state() - - def is_visible(self, idx): - item = idx.internalPointer() - if getattr(item, 'type', None) == TagTreeItem.TAG: - idx = idx.parent() - return self.isExpanded(idx) - - def recount(self, *args): - ''' - Rebuild the category tree, expand any categories that were expanded, - reset the search states, and reselect the current node. - ''' - if self.disable_recounting or not self.pane_is_visible: - return - self.refresh_signal_processed = True - ci = self.currentIndex() - if not ci.isValid(): - ci = self.indexAt(QPoint(10, 10)) - path = self.model().path_for_index(ci) if self.is_visible(ci) else None - expanded_categories, state_map = self.model().get_state() - self.set_new_model(state_map=state_map) - for category in expanded_categories: - self.expand(self.model().index_for_category(category)) - self._model.show_item_at_path(path) - - def item_expanded(self, idx): - ''' - Called by the expanded signal - ''' - self.setCurrentIndex(idx) - - def set_new_model(self, filter_categories_by=None, state_map={}): - ''' - There are cases where we need to rebuild the category tree without - attempting to reposition the current node. - ''' - try: - old = getattr(self, '_model', None) - if old is not None: - old.break_cycles() - self._model = TagsModel(self.db, parent=self, - hidden_categories=self.hidden_categories, - search_restriction=self.search_restriction, - drag_drop_finished=self.drag_drop_finished, - filter_categories_by=filter_categories_by, - collapse_model=self.collapse_model, - state_map=state_map) - self.setModel(self._model) - except: - # The DB must be gone. Set the model to None and hope that someone - # will call set_database later. I don't know if this in fact works. - # But perhaps a Bad Thing Happened, so print the exception - traceback.print_exc() - self._model = None - self.setModel(None) - # }}} - -class TagTreeItem(object): # {{{ - - CATEGORY = 0 - TAG = 1 - ROOT = 2 - - def __init__(self, data=None, category_icon=None, icon_map=None, - parent=None, tooltip=None, category_key=None, temporary=False): - self.parent = parent - self.children = [] - self.id_set = set() - self.is_gst = False - self.boxed = False - self.icon_state_map = list(map(QVariant, icon_map)) - if self.parent is not None: - self.parent.append(self) - if data is None: - self.type = self.ROOT - else: - self.type = self.TAG if category_icon is None else self.CATEGORY - if self.type == self.CATEGORY: - self.name, self.icon = map(QVariant, (data, category_icon)) - self.py_name = data - self.bold_font = QFont() - self.bold_font.setBold(True) - self.bold_font = QVariant(self.bold_font) - self.category_key = category_key - self.temporary = temporary - self.tag = Tag(data, category=category_key, - is_editable=category_key not in ['news', 'search', 'identifiers'], - is_searchable=category_key not in ['search']) - - elif self.type == self.TAG: - self.icon_state_map[0] = QVariant(data.icon) - self.tag = data - if tooltip: - self.tooltip = tooltip + ' ' - else: - self.tooltip = '' - - def break_cycles(self): - for x in self.children: - try: - x.break_cycles() - except: - pass - self.parent = self.icon_state_map = self.bold_font = self.tag = \ - self.icon = self.children = self.tooltip = \ - self.py_name = self.id_set = self.category_key = None - - def __str__(self): - if self.type == self.ROOT: - return 'ROOT' - if self.type == self.CATEGORY: - return 'CATEGORY:'+str(QVariant.toString(self.name))+':%d'%len(self.children) - return 'TAG:'+self.tag.name - - def row(self): - if self.parent is not None: - return self.parent.children.index(self) - return 0 - - def append(self, child): - child.parent = self - self.children.append(child) - - def data(self, role): - if role == Qt.UserRole: - return self - if self.type == self.TAG: - return self.tag_data(role) - if self.type == self.CATEGORY: - return self.category_data(role) - return NONE - - def category_data(self, role): - if role == Qt.DisplayRole: - return QVariant(self.py_name + ' [%d]'%len(self.child_tags())) - if role == Qt.EditRole: - return QVariant(self.py_name) - if role == Qt.DecorationRole: - if self.tag.state: - return self.icon_state_map[self.tag.state] - return self.icon - if role == Qt.FontRole: - return self.bold_font - if role == Qt.ToolTipRole and self.tooltip is not None: - return QVariant(self.tooltip) - return NONE - - def tag_data(self, role): - tag = self.tag - if tag.use_sort_as_name: - name = tag.sort - tt_author = True - else: - p = self - while p.parent.type != self.ROOT: - p = p.parent - if not tag.is_hierarchical: - name = tag.original_name - else: - name = tag.name - tt_author = False - if role == Qt.DisplayRole: - count = len(self.id_set) - count = count if count > 0 else tag.count - if count == 0: - return QVariant('%s'%(name)) - else: - return QVariant('[%d] %s'%(count, name)) - if role == Qt.EditRole: - return QVariant(tag.original_name) - if role == Qt.DecorationRole: - return self.icon_state_map[tag.state] - if role == Qt.ToolTipRole: - if tt_author: - if tag.tooltip is not None: - return QVariant('(%s) %s'%(tag.name, tag.tooltip)) - else: - return QVariant(tag.name) - if tag.tooltip: - return QVariant(self.tooltip + tag.tooltip) - else: - return QVariant(self.tooltip) - return NONE - - def toggle(self, set_to=None): - ''' - set_to: None => advance the state, otherwise a value from TAG_SEARCH_STATES - ''' - if set_to is None: - while True: - self.tag.state = (self.tag.state + 1)%5 - if self.tag.state == TAG_SEARCH_STATES['mark_plus'] or \ - self.tag.state == TAG_SEARCH_STATES['mark_minus']: - if self.tag.is_searchable: - break - elif self.tag.state == TAG_SEARCH_STATES['mark_plusplus'] or\ - self.tag.state == TAG_SEARCH_STATES['mark_minusminus']: - if self.tag.is_searchable and len(self.children) and \ - self.tag.is_hierarchical == '5state': - break - else: - break - else: - self.tag.state = set_to - - def all_children(self): - res = [] - def recurse(nodes, res): - for t in nodes: - res.append(t) - recurse(t.children, res) - recurse(self.children, res) - return res - - def child_tags(self): - res = [] - def recurse(nodes, res): - for t in nodes: - if t.type != TagTreeItem.CATEGORY: - res.append(t) - recurse(t.children, res) - recurse(self.children, res) - return res - # }}} - -class TagsModel(QAbstractItemModel): # {{{ - - def __init__(self, db, parent, hidden_categories=None, - search_restriction=None, drag_drop_finished=None, - filter_categories_by=None, collapse_model='disable', - state_map={}): - QAbstractItemModel.__init__(self, parent) - - # must do this here because 'QPixmap: Must construct a QApplication - # before a QPaintDevice'. The ':' at the end avoids polluting either of - # the other namespaces (alpha, '#', or '@') - iconmap = {} - for key in category_icon_map: - iconmap[key] = QIcon(I(category_icon_map[key])) - self.category_icon_map = TagsIcons(iconmap) - - self.categories_with_ratings = ['authors', 'series', 'publisher', 'tags'] - self.drag_drop_finished = drag_drop_finished - - self.icon_state_map = [None, QIcon(I('plus.png')), QIcon(I('plusplus.png')), - QIcon(I('minus.png')), QIcon(I('minusminus.png'))] - self.db = db - self.tags_view = parent - self.hidden_categories = hidden_categories - self.search_restriction = search_restriction - self.row_map = [] - self.filter_categories_by = filter_categories_by - self.collapse_model = collapse_model - - # Note that _get_category_nodes can indirectly change the - # user_categories dict. - - data = self._get_category_nodes(config['sort_tags_by']) - gst = db.prefs.get('grouped_search_terms', {}) - self.root_item = TagTreeItem(icon_map=self.icon_state_map) - self.category_nodes = [] - - last_category_node = None - category_node_map = {} - self.category_node_tree = {} - for i, key in enumerate(self.row_map): - if self.hidden_categories: - if key in self.hidden_categories: - continue - found = False - for cat in self.hidden_categories: - if cat.startswith('@') and key.startswith(cat + '.'): - found = True - if found: - continue - is_gst = False - if key.startswith('@') and key[1:] in gst: - tt = _(u'The grouped search term name is "{0}"').format(key[1:]) - is_gst = True - elif key == 'news': - tt = '' - else: - tt = _(u'The lookup/search name is "{0}"').format(key) - - if key.startswith('@'): - path_parts = [p for p in key.split('.')] - path = '' - last_category_node = self.root_item - tree_root = self.category_node_tree - for i,p in enumerate(path_parts): - path += p - if path not in category_node_map: - node = TagTreeItem(parent=last_category_node, - data=p[1:] if i == 0 else p, - category_icon=self.category_icon_map[key], - tooltip=tt if path == key else path, - category_key=path, - icon_map=self.icon_state_map) - last_category_node = node - category_node_map[path] = node - self.category_nodes.append(node) - node.can_be_edited = (not is_gst) and (i == (len(path_parts)-1)) - node.is_gst = is_gst - if not is_gst: - node.tag.is_hierarchical = '5state' - if not is_gst: - tree_root[p] = {} - tree_root = tree_root[p] - else: - last_category_node = category_node_map[path] - tree_root = tree_root[p] - path += '.' - else: - node = TagTreeItem(parent=self.root_item, - data=self.categories[key], - category_icon=self.category_icon_map[key], - tooltip=tt, category_key=key, - icon_map=self.icon_state_map) - node.is_gst = False - category_node_map[key] = node - last_category_node = node - self.category_nodes.append(node) - self._create_node_tree(data, state_map) - - def break_cycles(self): - self.root_item.break_cycles() - self.db = self.root_item = None - - def mimeTypes(self): - return ["application/calibre+from_library", - 'application/calibre+from_tag_browser'] - - def mimeData(self, indexes): - data = [] - for idx in indexes: - if idx.isValid(): - # get some useful serializable data - node = idx.internalPointer() - path = self.path_for_index(idx) - if node.type == TagTreeItem.CATEGORY: - d = (node.type, node.py_name, node.category_key) - else: - t = node.tag - p = node - while p.type != TagTreeItem.CATEGORY: - p = p.parent - d = (node.type, p.category_key, p.is_gst, t.original_name, - t.category, path) - data.append(d) - else: - data.append(None) - raw = bytearray(cPickle.dumps(data, -1)) - ans = QMimeData() - ans.setData('application/calibre+from_tag_browser', raw) - return ans - - def dropMimeData(self, md, action, row, column, parent): - fmts = set([unicode(x) for x in md.formats()]) - if not fmts.intersection(set(self.mimeTypes())): - return False - if "application/calibre+from_library" in fmts: - if action != Qt.CopyAction: - return False - return self.do_drop_from_library(md, action, row, column, parent) - elif 'application/calibre+from_tag_browser' in fmts: - return self.do_drop_from_tag_browser(md, action, row, column, parent) - - def do_drop_from_tag_browser(self, md, action, row, column, parent): - if not parent.isValid(): - return False - dest = parent.internalPointer() - if dest.type != TagTreeItem.CATEGORY: - return False - if not md.hasFormat('application/calibre+from_tag_browser'): - return False - data = str(md.data('application/calibre+from_tag_browser')) - src = cPickle.loads(data) - for s in src: - if s[0] != TagTreeItem.TAG: - return False - return self.move_or_copy_item_to_user_category(src, dest, action) - - def move_or_copy_item_to_user_category(self, src, dest, action): - ''' - src is a list of tuples representing items to copy. The tuple is - (type, containing category key, category key is global search term, - full name, category key, path to node) - The type must be TagTreeItem.TAG - dest is the TagTreeItem node to receive the items - action is Qt.CopyAction or Qt.MoveAction - ''' - def process_source_node(user_cats, src_parent, src_parent_is_gst, - is_uc, dest_key, node): - ''' - Copy/move an item and all its children to the destination - ''' - copied = False - src_name = node.tag.original_name - src_cat = node.tag.category - # delete the item if the source is a user category and action is move - if is_uc and not src_parent_is_gst and src_parent in user_cats and \ - action == Qt.MoveAction: - new_cat = [] - for tup in user_cats[src_parent]: - if src_name == tup[0] and src_cat == tup[1]: - continue - new_cat.append(list(tup)) - user_cats[src_parent] = new_cat - else: - copied = True - - # Now add the item to the destination user category - add_it = True - if not is_uc and src_cat == 'news': - src_cat = 'tags' - for tup in user_cats[dest_key]: - if src_name == tup[0] and src_cat == tup[1]: - add_it = False - if add_it: - user_cats[dest_key].append([src_name, src_cat, 0]) - - for c in node.children: - copied = process_source_node(user_cats, src_parent, src_parent_is_gst, - is_uc, dest_key, c) - return copied - - user_cats = self.db.prefs.get('user_categories', {}) - parent_node = None - copied = False - path = None - for s in src: - src_parent, src_parent_is_gst = s[1:3] - path = s[5] - parent_node = src_parent - - if src_parent.startswith('@'): - is_uc = True - src_parent = src_parent[1:] - else: - is_uc = False - dest_key = dest.category_key[1:] - - if dest_key not in user_cats: - continue - - node = self.index_for_path(path) - if node: - copied = process_source_node(user_cats, src_parent, src_parent_is_gst, - is_uc, dest_key, node.internalPointer()) - - self.db.prefs.set('user_categories', user_cats) - self.tags_view.recount() - - # Scroll to the item copied. If it was moved, scroll to the parent - if parent_node is not None: - self.clear_boxed() - m = self.tags_view.model() - if not copied: - p = path[-1] - if p == 0: - path = m.find_category_node(parent_node) - else: - path[-1] = p - 1 - idx = m.index_for_path(path) - self.tags_view.setExpanded(idx, True) - if idx.internalPointer().type == TagTreeItem.TAG: - m.show_item_at_index(idx, box=True) - else: - m.show_item_at_index(idx) - return True - - def do_drop_from_library(self, md, action, row, column, parent): - idx = parent - if idx.isValid(): - self.tags_view.setCurrentIndex(idx) - node = self.data(idx, Qt.UserRole) - if node.type == TagTreeItem.TAG: - fm = self.db.metadata_for_field(node.tag.category) - if node.tag.category in \ - ('tags', 'series', 'authors', 'rating', 'publisher') or \ - (fm['is_custom'] and ( - fm['datatype'] in ['text', 'rating', 'series', - 'enumeration'] or - (fm['datatype'] == 'composite' and - fm['display'].get('make_category', False)))): - mime = 'application/calibre+from_library' - ids = list(map(int, str(md.data(mime)).split())) - self.handle_drop(node, ids) - return True - elif node.type == TagTreeItem.CATEGORY: - fm_dest = self.db.metadata_for_field(node.category_key) - if fm_dest['kind'] == 'user': - fm_src = self.db.metadata_for_field(md.column_name) - if md.column_name in ['authors', 'publisher', 'series'] or \ - (fm_src['is_custom'] and ( - (fm_src['datatype'] in ['series', 'text', 'enumeration'] and - not fm_src['is_multiple']))or - (fm_src['datatype'] == 'composite' and - fm_src['display'].get('make_category', False))): - mime = 'application/calibre+from_library' - ids = list(map(int, str(md.data(mime)).split())) - self.handle_user_category_drop(node, ids, md.column_name) - return True - return False - - def handle_user_category_drop(self, on_node, ids, column): - categories = self.db.prefs.get('user_categories', {}) - category = categories.get(on_node.category_key[1:], None) - if category is None: - return - fm_src = self.db.metadata_for_field(column) - for id in ids: - label = fm_src['label'] - if not fm_src['is_custom']: - if label == 'authors': - items = self.db.get_authors_with_ids() - items = [(i[0], i[1].replace('|', ',')) for i in items] - value = self.db.authors(id, index_is_id=True) - value = [v.replace('|', ',') for v in value.split(',')] - elif label == 'publisher': - items = self.db.get_publishers_with_ids() - value = self.db.publisher(id, index_is_id=True) - elif label == 'series': - items = self.db.get_series_with_ids() - value = self.db.series(id, index_is_id=True) - else: - items = self.db.get_custom_items_with_ids(label=label) - if fm_src['datatype'] != 'composite': - value = self.db.get_custom(id, label=label, index_is_id=True) - else: - value = self.db.get_property(id, loc=fm_src['rec_index'], - index_is_id=True) - if value is None: - return - if not isinstance(value, list): - value = [value] - for val in value: - for (v, c, id) in category: - if v == val and c == column: - break - else: - category.append([val, column, 0]) - categories[on_node.category_key[1:]] = category - self.db.prefs.set('user_categories', categories) - self.tags_view.recount() - - def handle_drop(self, on_node, ids): - #print 'Dropped ids:', ids, on_node.tag - key = on_node.tag.category - if (key == 'authors' and len(ids) >= 5): - if not confirm('

'+_('Changing the authors for several books can ' - 'take a while. Are you sure?') - +'

', 'tag_browser_drop_authors', self.tags_view): - return - elif len(ids) > 15: - if not confirm('

'+_('Changing the metadata for that many books ' - 'can take a while. Are you sure?') - +'

', 'tag_browser_many_changes', self.tags_view): - return - - fm = self.db.metadata_for_field(key) - is_multiple = fm['is_multiple'] - val = on_node.tag.original_name - for id in ids: - mi = self.db.get_metadata(id, index_is_id=True) - - # Prepare to ignore the author, unless it is changed. Title is - # always ignored -- see the call to set_metadata - set_authors = False - - # Author_sort cannot change explicitly. Changing the author might - # change it. - mi.author_sort = None # Never will change by itself. - - if key == 'authors': - mi.authors = [val] - set_authors=True - elif fm['datatype'] == 'rating': - mi.set(key, len(val) * 2) - elif fm['is_custom'] and fm['datatype'] == 'series': - mi.set(key, val, extra=1.0) - elif is_multiple: - new_val = mi.get(key, []) - if val in new_val: - # Fortunately, only one field can change, so the continue - # won't break anything - continue - new_val.append(val) - mi.set(key, new_val) - else: - mi.set(key, val) - self.db.set_metadata(id, mi, set_title=False, - set_authors=set_authors, commit=False) - self.db.commit() - self.drag_drop_finished.emit(ids) - - def set_search_restriction(self, s): - self.search_restriction = s - - def _get_category_nodes(self, sort): - ''' - Called by __init__. Do not directly call this method. - ''' - self.row_map = [] - self.categories = {} - - # Get the categories - if self.search_restriction: - try: - data = self.db.get_categories(sort=sort, - icon_map=self.category_icon_map, - ids=self.db.search('', return_matches=True)) - except: - data = self.db.get_categories(sort=sort, icon_map=self.category_icon_map) - self.tags_view.restriction_error.emit() - else: - data = self.db.get_categories(sort=sort, icon_map=self.category_icon_map) - - # Reconstruct the user categories, putting them into metadata - self.db.field_metadata.remove_dynamic_categories() - tb_cats = self.db.field_metadata - for user_cat in sorted(self.db.prefs.get('user_categories', {}).keys(), - key=sort_key): - cat_name = '@' + user_cat # add the '@' to avoid name collision - while True: - try: - tb_cats.add_user_category(label=cat_name, name=user_cat) - dot = cat_name.rfind('.') - if dot < 0: - break - cat_name = cat_name[:dot] - except ValueError: - break - - for cat in sorted(self.db.prefs.get('grouped_search_terms', {}).keys(), - key=sort_key): - if (u'@' + cat) in data: - try: - tb_cats.add_user_category(label=u'@' + cat, name=cat) - except ValueError: - traceback.print_exc() - self.db.data.change_search_locations(self.db.field_metadata.get_search_terms()) - - if len(saved_searches().names()): - tb_cats.add_search_category(label='search', name=_('Searches')) - - if self.filter_categories_by: - for category in data.keys(): - data[category] = [t for t in data[category] - if lower(t.name).find(self.filter_categories_by) >= 0] - - tb_categories = self.db.field_metadata - for category in tb_categories: - if category in data: # The search category can come and go - self.row_map.append(category) - self.categories[category] = tb_categories[category]['name'] - return data - - def refresh(self, data=None): - ''' - Here to trap usages of refresh in the old architecture. Can eventually - be removed. - ''' - print 'TagsModel: refresh called!' - traceback.print_stack() - return False - - def _create_node_tree(self, data, state_map): - ''' - Called by __init__. Do not directly call this method. - ''' - sort_by = config['sort_tags_by'] - - if data is None: - print '_create_node_tree: no data!' - traceback.print_stack() - return - - collapse = gprefs['tags_browser_collapse_at'] - collapse_model = self.collapse_model - if collapse == 0: - collapse_model = 'disable' - elif collapse_model != 'disable': - if sort_by == 'name': - collapse_template = tweaks['categories_collapsed_name_template'] - elif sort_by == 'rating': - collapse_model = 'partition' - collapse_template = tweaks['categories_collapsed_rating_template'] - else: - collapse_model = 'partition' - collapse_template = tweaks['categories_collapsed_popularity_template'] - - def process_one_node(category, state_map): # {{{ - collapse_letter = None - category_index = self.createIndex(category.row(), 0, category) - category_node = category_index.internalPointer() - key = category_node.category_key - if key not in data: - return - cat_len = len(data[key]) - if cat_len <= 0: - return - - category_child_map = {} - fm = self.db.field_metadata[key] - clear_rating = True if key not in self.categories_with_ratings and \ - not fm['is_custom'] and \ - not fm['kind'] == 'user' \ - else False - in_uc = fm['kind'] == 'user' - tt = key if in_uc else None - - if collapse_model == 'first letter': - # Build a list of 'equal' first letters by looking for - # overlapping ranges. If a range overlaps another, then the - # letters are assumed to be equivalent. ICU collating is complex - # beyond belief. This mechanism lets us determine the logical - # first character from ICU's standpoint. - chardict = {} - for idx,tag in enumerate(data[key]): - if not tag.sort: - c = ' ' - else: - c = icu_upper(tag.sort[0]) - if c not in chardict: - chardict[c] = [idx, idx] - else: - chardict[c][1] = idx - - # sort the ranges to facilitate detecting overlap - ranges = sorted([(v[0], v[1], c) for c,v in chardict.items()]) - - # Create a list of 'first letters' to use for each item in - # the category. The list is generated using the ranges. Overlaps - # are filled with the character that first occurs. - cl_list = list(repeat(None, len(data[key]))) - for t in ranges: - start = t[0] - c = t[2] - if cl_list[start] is None: - nc = c - else: - nc = cl_list[start] - for i in range(start, t[1]+1): - cl_list[i] = nc - - for idx,tag in enumerate(data[key]): - if clear_rating: - tag.avg_rating = None - tag.state = state_map.get((tag.name, tag.category), 0) - - if collapse_model != 'disable' and cat_len > collapse: - if collapse_model == 'partition': - if (idx % collapse) == 0: - d = {'first': tag} - if cat_len > idx + collapse: - d['last'] = data[key][idx+collapse-1] - else: - d['last'] = data[key][cat_len-1] - name = eval_formatter.safe_format(collapse_template, - d, 'TAG_VIEW', None) - self.beginInsertRows(category_index, 999998, 999999) #len(data[key])-1) - sub_cat = TagTreeItem(parent=category, data = name, - tooltip = None, temporary=True, - category_icon = category_node.icon, - category_key=category_node.category_key, - icon_map=self.icon_state_map) - sub_cat.tag.is_searchable = False - self.endInsertRows() - else: # by 'first letter' - cl = cl_list[idx] - if cl != collapse_letter: - collapse_letter = cl - sub_cat = TagTreeItem(parent=category, - data = collapse_letter, - category_icon = category_node.icon, - tooltip = None, temporary=True, - category_key=category_node.category_key, - icon_map=self.icon_state_map) - node_parent = sub_cat - else: - node_parent = category - - # category display order is important here. The following works - # only of all the non-user categories are displayed before the - # user categories - components = [t.strip() for t in tag.original_name.split('.') - if t.strip()] - if len(components) == 0 or '.'.join(components) != tag.original_name: - components = [tag.original_name] - if (not tag.is_hierarchical) and (in_uc or - (fm['is_custom'] and fm['display'].get('is_names', False)) or - key in ['authors', 'publisher', 'news', 'formats', 'rating'] or - key not in self.db.prefs.get('categories_using_hierarchy', []) or - len(components) == 1): - self.beginInsertRows(category_index, 999998, 999999) - n = TagTreeItem(parent=node_parent, data=tag, tooltip=tt, - icon_map=self.icon_state_map) - if tag.id_set is not None: - n.id_set |= tag.id_set - category_child_map[tag.name, tag.category] = n - self.endInsertRows() - else: - for i,comp in enumerate(components): - if i == 0: - child_map = category_child_map - else: - child_map = dict([((t.tag.name, t.tag.category), t) - for t in node_parent.children - if t.type != TagTreeItem.CATEGORY]) - if (comp,tag.category) in child_map: - node_parent = child_map[(comp,tag.category)] - node_parent.tag.is_hierarchical = \ - '5state' if tag.category != 'search' else '3state' - else: - if i < len(components)-1: - t = copy.copy(tag) - t.original_name = '.'.join(components[:i+1]) - if key != 'search': - # This 'manufactured' intermediate node can - # be searched, but cannot be edited. - t.is_editable = False - else: - t.is_searchable = t.is_editable = False - else: - t = tag - if not in_uc: - t.original_name = t.name - t.is_hierarchical = \ - '5state' if t.category != 'search' else '3state' - t.name = comp - self.beginInsertRows(category_index, 999998, 999999) - node_parent = TagTreeItem(parent=node_parent, data=t, - tooltip=tt, icon_map=self.icon_state_map) - child_map[(comp,tag.category)] = node_parent - self.endInsertRows() - # This id_set must not be None - node_parent.id_set |= tag.id_set - return - # }}} - - for category in self.category_nodes: - process_one_node(category, state_map.get(category.py_name, {})) - - def get_state(self): - state_map = {} - expanded_categories = [] - for row, category in enumerate(self.category_nodes): - if self.tags_view.isExpanded(self.index(row, 0, QModelIndex())): - expanded_categories.append(category.py_name) - states = [c.tag.state for c in category.child_tags()] - names = [(c.tag.name, c.tag.category) for c in category.child_tags()] - state_map[category.py_name] = dict(izip(names, states)) - return expanded_categories, state_map - - def index_for_category(self, name): - for row, category in enumerate(self.category_nodes): - if category.py_name == name: - return self.index(row, 0, QModelIndex()) - - def columnCount(self, parent): - return 1 - - def data(self, index, role): - if not index.isValid(): - return NONE - item = index.internalPointer() - return item.data(role) - - def setData(self, index, value, role=Qt.EditRole): - if not index.isValid(): - return NONE - # set up to reposition at the same item. We can do this except if - # working with the last item and that item is deleted, in which case - # we position at the parent label - path = index.model().path_for_index(index) - val = unicode(value.toString()).strip() - if not val: - error_dialog(self.tags_view, _('Item is blank'), - _('An item cannot be set to nothing. Delete it instead.')).exec_() - return False - item = index.internalPointer() - if item.type == TagTreeItem.CATEGORY and item.category_key.startswith('@'): - if val.find('.') >= 0: - error_dialog(self.tags_view, _('Rename user category'), - _('You cannot use periods in the name when ' - 'renaming user categories'), show=True) - return False - - user_cats = self.db.prefs.get('user_categories', {}) - user_cat_keys_lower = [icu_lower(k) for k in user_cats] - ckey = item.category_key[1:] - ckey_lower = icu_lower(ckey) - dotpos = ckey.rfind('.') - if dotpos < 0: - nkey = val - else: - nkey = ckey[:dotpos+1] + val - nkey_lower = icu_lower(nkey) - for c in sorted(user_cats.keys(), key=sort_key): - if icu_lower(c).startswith(ckey_lower): - if len(c) == len(ckey): - if strcmp(ckey, nkey) != 0 and \ - nkey_lower in user_cat_keys_lower: - error_dialog(self.tags_view, _('Rename user category'), - _('The name %s is already used')%nkey, show=True) - return False - user_cats[nkey] = user_cats[ckey] - del user_cats[ckey] - elif c[len(ckey)] == '.': - rest = c[len(ckey):] - if strcmp(ckey, nkey) != 0 and \ - icu_lower(nkey + rest) in user_cat_keys_lower: - error_dialog(self.tags_view, _('Rename user category'), - _('The name %s is already used')%(nkey+rest), show=True) - return False - user_cats[nkey + rest] = user_cats[ckey + rest] - del user_cats[ckey + rest] - self.db.prefs.set('user_categories', user_cats) - self.tags_view.set_new_model() - # must not use 'self' below because the model has changed! - p = self.tags_view.model().find_category_node('@' + nkey) - self.tags_view.model().show_item_at_path(p) - return True - - key = item.tag.category - name = item.tag.original_name - # make certain we know about the item's category - if key not in self.db.field_metadata: - return False - if key == 'authors': - if val.find('&') >= 0: - error_dialog(self.tags_view, _('Invalid author name'), - _('Author names cannot contain & characters.')).exec_() - return False - if key == 'search': - if val in saved_searches().names(): - error_dialog(self.tags_view, _('Duplicate search name'), - _('The saved search name %s is already used.')%val).exec_() - return False - saved_searches().rename(unicode(item.data(role).toString()), val) - item.tag.name = val - self.tags_view.search_item_renamed.emit() # Does a refresh - else: - if key == 'series': - self.db.rename_series(item.tag.id, val) - elif key == 'publisher': - self.db.rename_publisher(item.tag.id, val) - elif key == 'tags': - self.db.rename_tag(item.tag.id, val) - elif key == 'authors': - self.db.rename_author(item.tag.id, val) - elif self.db.field_metadata[key]['is_custom']: - self.db.rename_custom_item(item.tag.id, val, - label=self.db.field_metadata[key]['label']) - self.tags_view.tag_item_renamed.emit() - item.tag.name = val - self.rename_item_in_all_user_categories(name, key, val) - self.tags_view.refresh_required.emit() - self.show_item_at_path(path) - return True - - def rename_item_in_all_user_categories(self, item_name, item_category, new_name): - ''' - Search all user categories for items named item_name with category - item_category and rename them to new_name. The caller must arrange to - redisplay the tree as appropriate (recount or set_new_model) - ''' - user_cats = self.db.prefs.get('user_categories', {}) - for k in user_cats.keys(): - new_contents = [] - for tup in user_cats[k]: - if tup[0] == item_name and tup[1] == item_category: - new_contents.append([new_name, item_category, 0]) - else: - new_contents.append(tup) - user_cats[k] = new_contents - self.db.prefs.set('user_categories', user_cats) - - def delete_item_from_all_user_categories(self, item_name, item_category): - ''' - Search all user categories for items named item_name with category - item_category and delete them. The caller must arrange to redisplay the - tree as appropriate (recount or set_new_model) - ''' - user_cats = self.db.prefs.get('user_categories', {}) - for cat in user_cats.keys(): - self.delete_item_from_user_category(cat, item_name, item_category, - user_categories=user_cats) - self.db.prefs.set('user_categories', user_cats) - - def delete_item_from_user_category(self, category, item_name, item_category, - user_categories=None): - if user_categories is not None: - user_cats = user_categories - else: - user_cats = self.db.prefs.get('user_categories', {}) - new_contents = [] - for tup in user_cats[category]: - if tup[0] != item_name or tup[1] != item_category: - new_contents.append(tup) - user_cats[category] = new_contents - if user_categories is None: - self.db.prefs.set('user_categories', user_cats) - - def headerData(self, *args): - return NONE - - def flags(self, index, *args): - ans = Qt.ItemIsEnabled|Qt.ItemIsSelectable|Qt.ItemIsEditable - if index.isValid(): - node = self.data(index, Qt.UserRole) - if node.type == TagTreeItem.TAG: - if node.tag.is_editable: - ans |= Qt.ItemIsDragEnabled - fm = self.db.metadata_for_field(node.tag.category) - if node.tag.category in \ - ('tags', 'series', 'authors', 'rating', 'publisher') or \ - (fm['is_custom'] and \ - fm['datatype'] in ['text', 'rating', 'series', 'enumeration']): - ans |= Qt.ItemIsDropEnabled - else: - ans |= Qt.ItemIsDropEnabled - return ans - - def supportedDropActions(self): - return Qt.CopyAction|Qt.MoveAction - - def path_for_index(self, index): - ans = [] - while index.isValid(): - ans.append(index.row()) - index = self.parent(index) - ans.reverse() - return ans - - def index_for_path(self, path): - parent = QModelIndex() - for i in path: - parent = self.index(i, 0, parent) - if not parent.isValid(): - return QModelIndex() - return parent - - def index(self, row, column, parent): - if not self.hasIndex(row, column, parent): - return QModelIndex() - - if not parent.isValid(): - parent_item = self.root_item - else: - parent_item = parent.internalPointer() - - try: - child_item = parent_item.children[row] - except IndexError: - return QModelIndex() - - ans = self.createIndex(row, column, child_item) - return ans - - def parent(self, index): - if not index.isValid(): - return QModelIndex() - - child_item = index.internalPointer() - parent_item = getattr(child_item, 'parent', None) - - if parent_item is self.root_item or parent_item is None: - return QModelIndex() - - ans = self.createIndex(parent_item.row(), 0, parent_item) - return ans - - def rowCount(self, parent): - if parent.column() > 0: - return 0 - - if not parent.isValid(): - parent_item = self.root_item - else: - parent_item = parent.internalPointer() - - return len(parent_item.children) - - def reset_all_states(self, except_=None): - update_list = [] - def process_tag(tag_item): - tag = tag_item.tag - if tag is except_: - tag_index = self.createIndex(tag_item.row(), 0, tag_item) - self.dataChanged.emit(tag_index, tag_index) - elif tag.state != 0 or tag in update_list: - tag_index = self.createIndex(tag_item.row(), 0, tag_item) - tag.state = 0 - update_list.append(tag) - self.dataChanged.emit(tag_index, tag_index) - for t in tag_item.children: - process_tag(t) - - for t in self.root_item.children: - process_tag(t) - - def clear_state(self): - self.reset_all_states() - - def toggle(self, index, exclusive, set_to=None): - ''' - exclusive: clear all states before applying this one - set_to: None => advance the state, otherwise a value from TAG_SEARCH_STATES - ''' - if not index.isValid(): return False - item = index.internalPointer() - item.toggle(set_to=set_to) - if exclusive: - self.reset_all_states(except_=item.tag) - self.dataChanged.emit(index, index) - return True - - def tokens(self): - ans = [] - # Tags can be in the news and the tags categories. However, because of - # the desire to use two different icons (tags and news), the nodes are - # not shared, which can lead to the possibility of searching twice for - # the same tag. The tags_seen set helps us prevent that - tags_seen = set() - # Tag nodes are in their own category and possibly in user categories. - # They will be 'checked' in both places, but we want to put the node - # into the search string only once. The nodes_seen set helps us do that - nodes_seen = set() - - node_searches = {TAG_SEARCH_STATES['mark_plus'] : 'true', - TAG_SEARCH_STATES['mark_plusplus'] : '.true', - TAG_SEARCH_STATES['mark_minus'] : 'false', - TAG_SEARCH_STATES['mark_minusminus'] : '.false'} - - for node in self.category_nodes: - if node.tag.state: - if node.category_key == "news": - if node_searches[node.tag.state] == 'true': - ans.append('tags:"=' + _('News') + '"') - else: - ans.append('( not tags:"=' + _('News') + '")') - else: - ans.append('%s:%s'%(node.category_key, node_searches[node.tag.state])) - - key = node.category_key - for tag_item in node.all_children(): - if tag_item.type == TagTreeItem.CATEGORY: - if self.collapse_model == 'first letter' and \ - tag_item.temporary and not key.startswith('@') \ - and tag_item.tag.state: - k = 'author_sort' if key == 'authors' else key - letters_seen = {} - for subnode in tag_item.children: - letters_seen[subnode.tag.sort[0]] = True - charclass = ''.join(letters_seen) - if k == 'author_sort': - expr = r'%s:"~(^[%s])|(&\\s*[%s])"'%(k, charclass, charclass) - else: - expr = r'%s:"~^[%s]"'%(k, charclass) - if node_searches[tag_item.tag.state] == 'true': - ans.append(expr) - else: - ans.append('(not ' + expr + ')') - continue - tag = tag_item.tag - if tag.state != TAG_SEARCH_STATES['clear']: - if tag.state == TAG_SEARCH_STATES['mark_minus'] or \ - tag.state == TAG_SEARCH_STATES['mark_minusminus']: - prefix = ' not ' - else: - prefix = '' - category = tag.category if key != 'news' else 'tag' - add_colon = False - if self.db.field_metadata[tag.category]['is_csp']: - add_colon = True - - if tag.name and tag.name[0] == u'\u2605': # char is a star. Assume rating - ans.append('%s%s:%s'%(prefix, category, len(tag.name))) - else: - name = tag.original_name - use_prefix = tag.state in [TAG_SEARCH_STATES['mark_plusplus'], - TAG_SEARCH_STATES['mark_minusminus']] - if category == 'tags': - if name in tags_seen: - continue - tags_seen.add(name) - if tag in nodes_seen: - continue - nodes_seen.add(tag) - n = name.replace(r'"', r'\"') - if name.startswith('.'): - n = '.' + n - ans.append('%s%s:"=%s%s%s"'%(prefix, category, - '.' if use_prefix else '', n, - ':' if add_colon else '')) - return ans - - def find_item_node(self, key, txt, start_path, equals_match=False): - ''' - Search for an item (a node) in the tags browser list that matches both - the key (exact case-insensitive match) and txt (not equals_match => - case-insensitive contains match; equals_match => case_insensitive - equal match). Returns the path to the node. Note that paths are to a - location (second item, fourth item, 25 item), not to a node. If - start_path is None, the search starts with the topmost node. If the tree - is changed subsequent to calling this method, the path can easily refer - to a different node or no node at all. - ''' - if not txt: - return None - txt = lower(txt) if not equals_match else txt - self.path_found = None - if start_path is None: - start_path = [] - - def process_tag(depth, tag_index, tag_item, start_path): - path = self.path_for_index(tag_index) - if depth < len(start_path) and path[depth] <= start_path[depth]: - return False - tag = tag_item.tag - if tag is None: - return False - name = tag.original_name - if (equals_match and strcmp(name, txt) == 0) or \ - (not equals_match and lower(name).find(txt) >= 0): - self.path_found = path - return True - for i,c in enumerate(tag_item.children): - if process_tag(depth+1, self.createIndex(i, 0, c), c, start_path): - return True - return False - - def process_level(depth, category_index, start_path): - path = self.path_for_index(category_index) - if depth < len(start_path): - if path[depth] < start_path[depth]: - return False - if path[depth] > start_path[depth]: - start_path = path - my_key = category_index.internalPointer().category_key - for j in xrange(self.rowCount(category_index)): - tag_index = self.index(j, 0, category_index) - tag_item = tag_index.internalPointer() - if tag_item.type == TagTreeItem.CATEGORY: - if process_level(depth+1, tag_index, start_path): - return True - elif not key or strcmp(key, my_key) == 0: - if process_tag(depth+1, tag_index, tag_item, start_path): - return True - return False - - for i in xrange(self.rowCount(QModelIndex())): - if process_level(0, self.index(i, 0, QModelIndex()), start_path): - break - return self.path_found - - def find_category_node(self, key, parent=QModelIndex()): - ''' - Search for an category node (a top-level node) in the tags browser list - that matches the key (exact case-insensitive match). Returns the path to - the node. Paths are as in find_item_node. - ''' - if not key: - return None - - for i in xrange(self.rowCount(parent)): - idx = self.index(i, 0, parent) - node = idx.internalPointer() - if node.type == TagTreeItem.CATEGORY: - ckey = node.category_key - if strcmp(ckey, key) == 0: - return self.path_for_index(idx) - if len(node.children): - v = self.find_category_node(key, idx) - if v is not None: - return v - return None - - def show_item_at_path(self, path, box=False, - position=QTreeView.PositionAtCenter): - ''' - Scroll the browser and open categories to show the item referenced by - path. If possible, the item is placed in the center. If box=True, a - box is drawn around the item. - ''' - if path: - self.show_item_at_index(self.index_for_path(path), box=box, - position=position) - - def show_item_at_index(self, idx, box=False, - position=QTreeView.PositionAtCenter): - if idx.isValid(): - self.tags_view.setCurrentIndex(idx) - self.tags_view.scrollTo(idx, position) - self.tags_view.setCurrentIndex(idx) - if box: - tag_item = idx.internalPointer() - tag_item.boxed = True - self.dataChanged.emit(idx, idx) - - def clear_boxed(self): - ''' - Clear all boxes around items. - ''' - def process_tag(tag_index, tag_item): - if tag_item.boxed: - tag_item.boxed = False - self.dataChanged.emit(tag_index, tag_index) - for i,c in enumerate(tag_item.children): - process_tag(self.index(i, 0, tag_index), c) - - def process_level(category_index): - for j in xrange(self.rowCount(category_index)): - tag_index = self.index(j, 0, category_index) - tag_item = tag_index.internalPointer() - if tag_item.boxed: - tag_item.boxed = False - self.dataChanged.emit(tag_index, tag_index) - if tag_item.type == TagTreeItem.CATEGORY: - process_level(tag_index) - else: - process_tag(tag_index, tag_item) - - for i in xrange(self.rowCount(QModelIndex())): - process_level(self.index(i, 0, QModelIndex())) - - def get_filter_categories_by(self): - return self.filter_categories_by - - # }}} - -class TagBrowserMixin(object): # {{{ - - def __init__(self, db): - self.library_view.model().count_changed_signal.connect(self.tags_view.recount) - self.tags_view.set_database(db, self.tag_match, self.sort_by) - self.tags_view.tags_marked.connect(self.search.set_search_string) - self.tags_view.tag_list_edit.connect(self.do_tags_list_edit) - self.tags_view.edit_user_category.connect(self.do_edit_user_categories) - self.tags_view.delete_user_category.connect(self.do_delete_user_category) - self.tags_view.del_item_from_user_cat.connect(self.do_del_item_from_user_cat) - self.tags_view.add_subcategory.connect(self.do_add_subcategory) - self.tags_view.add_item_to_user_cat.connect(self.do_add_item_to_user_cat) - self.tags_view.saved_search_edit.connect(self.do_saved_search_edit) - self.tags_view.rebuild_saved_searches.connect(self.do_rebuild_saved_searches) - self.tags_view.author_sort_edit.connect(self.do_author_sort_edit) - self.tags_view.tag_item_renamed.connect(self.do_tag_item_renamed) - self.tags_view.search_item_renamed.connect(self.saved_searches_changed) - self.tags_view.drag_drop_finished.connect(self.drag_drop_finished) - self.tags_view.restriction_error.connect(self.do_restriction_error, - type=Qt.QueuedConnection) - - for text, func, args, cat_name in ( - (_('Manage Authors'), - self.do_author_sort_edit, (self, None), 'authors'), - (_('Manage Series'), - self.do_tags_list_edit, (None, 'series'), 'series'), - (_('Manage Publishers'), - self.do_tags_list_edit, (None, 'publisher'), 'publisher'), - (_('Manage Tags'), - self.do_tags_list_edit, (None, 'tags'), 'tags'), - (_('Manage User Categories'), - self.do_edit_user_categories, (None,), 'user:'), - (_('Manage Saved Searches'), - self.do_saved_search_edit, (None,), 'search') - ): - self.manage_items_button.menu().addAction( - QIcon(I(category_icon_map[cat_name])), - text, partial(func, *args)) - - def do_restriction_error(self): - error_dialog(self.tags_view, _('Invalid search restriction'), - _('The current search restriction is invalid'), show=True) - - def do_add_subcategory(self, on_category_key, new_category_name=None): - ''' - Add a subcategory to the category 'on_category'. If new_category_name is - None, then a default name is shown and the user is offered the - opportunity to edit the name. - ''' - db = self.library_view.model().db - user_cats = db.prefs.get('user_categories', {}) - - # Ensure that the temporary name we will use is not already there - i = 0 - if new_category_name is not None: - new_name = new_category_name.replace('.', '') - else: - new_name = _('New Category').replace('.', '') - n = new_name - while True: - new_cat = on_category_key[1:] + '.' + n - if new_cat not in user_cats: - break - i += 1 - n = new_name + unicode(i) - # Add the new category - user_cats[new_cat] = [] - db.prefs.set('user_categories', user_cats) - self.tags_view.set_new_model() - m = self.tags_view.model() - idx = m.index_for_path(m.find_category_node('@' + new_cat)) - m.show_item_at_index(idx) - # Open the editor on the new item to rename it - if new_category_name is None: - self.tags_view.edit(idx) - - def do_edit_user_categories(self, on_category=None): - ''' - Open the user categories editor. - ''' - db = self.library_view.model().db - d = TagCategories(self, db, on_category) - if d.exec_() == d.Accepted: - db.prefs.set('user_categories', d.categories) - db.field_metadata.remove_user_categories() - for k in d.categories: - db.field_metadata.add_user_category('@' + k, k) - db.data.change_search_locations(db.field_metadata.get_search_terms()) - self.tags_view.set_new_model() - - def do_delete_user_category(self, category_name): - ''' - Delete the user category named category_name. Any leading '@' is removed - ''' - if category_name.startswith('@'): - category_name = category_name[1:] - db = self.library_view.model().db - user_cats = db.prefs.get('user_categories', {}) - cat_keys = sorted(user_cats.keys(), key=sort_key) - has_children = False - found = False - for k in cat_keys: - if k == category_name: - found = True - has_children = len(user_cats[k]) - elif k.startswith(category_name + '.'): - has_children = True - if not found: - return error_dialog(self.tags_view, _('Delete user category'), - _('%s is not a user category')%category_name, show=True) - if has_children: - if not question_dialog(self.tags_view, _('Delete user category'), - _('%s contains items. Do you really ' - 'want to delete it?')%category_name): - return - for k in cat_keys: - if k == category_name: - del user_cats[k] - elif k.startswith(category_name + '.'): - del user_cats[k] - db.prefs.set('user_categories', user_cats) - self.tags_view.set_new_model() - - def do_del_item_from_user_cat(self, user_cat, item_name, item_category): - ''' - Delete the item (item_name, item_category) from the user category with - key user_cat. Any leading '@' characters are removed - ''' - if user_cat.startswith('@'): - user_cat = user_cat[1:] - db = self.library_view.model().db - user_cats = db.prefs.get('user_categories', {}) - if user_cat not in user_cats: - error_dialog(self.tags_view, _('Remove category'), - _('User category %s does not exist')%user_cat, - show=True) - return - self.tags_view.model().delete_item_from_user_category(user_cat, - item_name, item_category) - self.tags_view.recount() - - def do_add_item_to_user_cat(self, dest_category, src_name, src_category): - ''' - Add the item src_name in src_category to the user category - dest_category. Any leading '@' is removed - ''' - db = self.library_view.model().db - user_cats = db.prefs.get('user_categories', {}) - - if dest_category and dest_category.startswith('@'): - dest_category = dest_category[1:] - - if dest_category not in user_cats: - return error_dialog(self.tags_view, _('Add to user category'), - _('A user category %s does not exist')%dest_category, show=True) - - # Now add the item to the destination user category - add_it = True - if src_category == 'news': - src_category = 'tags' - for tup in user_cats[dest_category]: - if src_name == tup[0] and src_category == tup[1]: - add_it = False - if add_it: - user_cats[dest_category].append([src_name, src_category, 0]) - db.prefs.set('user_categories', user_cats) - self.tags_view.recount() - - def do_tags_list_edit(self, tag, category): - ''' - Open the 'manage_X' dialog where X == category. If tag is not None, the - dialog will position the editor on that item. - ''' - db=self.library_view.model().db - if category == 'tags': - result = db.get_tags_with_ids() - key = sort_key - elif category == 'series': - result = db.get_series_with_ids() - key = lambda x:sort_key(title_sort(x)) - elif category == 'publisher': - result = db.get_publishers_with_ids() - key = sort_key - else: # should be a custom field - cc_label = None - if category in db.field_metadata: - cc_label = db.field_metadata[category]['label'] - result = db.get_custom_items_with_ids(label=cc_label) - else: - result = [] - key = sort_key - - d = TagListEditor(self, tag_to_match=tag, data=result, key=key) - d.exec_() - if d.result() == d.Accepted: - to_rename = d.to_rename # dict of new text to old id - to_delete = d.to_delete # list of ids - orig_name = d.original_names # dict of id: name - - rename_func = None - if category == 'tags': - rename_func = db.rename_tag - delete_func = db.delete_tag_using_id - elif category == 'series': - rename_func = db.rename_series - delete_func = db.delete_series_using_id - elif category == 'publisher': - rename_func = db.rename_publisher - delete_func = db.delete_publisher_using_id - else: - rename_func = partial(db.rename_custom_item, label=cc_label) - delete_func = partial(db.delete_custom_item_using_id, label=cc_label) - m = self.tags_view.model() - if rename_func: - for item in to_delete: - delete_func(item) - m.delete_item_from_all_user_categories(orig_name[item], category) - for old_id in to_rename: - rename_func(old_id, new_name=unicode(to_rename[old_id])) - m.rename_item_in_all_user_categories(orig_name[old_id], - category, unicode(to_rename[old_id])) - - # Clean up the library view - self.do_tag_item_renamed() - self.tags_view.recount() - - def do_tag_item_renamed(self): - # Clean up library view and search - # get information to redo the selection - rows = [r.row() for r in \ - self.library_view.selectionModel().selectedRows()] - m = self.library_view.model() - ids = [m.id(r) for r in rows] - - m.refresh(reset=False) - m.research() - self.library_view.select_rows(ids) - # refreshing the tags view happens at the emit()/call() site - - def do_author_sort_edit(self, parent, id, select_sort=True): - ''' - Open the manage authors dialog - ''' - db = self.library_view.model().db - editor = EditAuthorsDialog(parent, db, id, select_sort) - d = editor.exec_() - if d: - for (id, old_author, new_author, new_sort) in editor.result: - if old_author != new_author: - # The id might change if the new author already exists - id = db.rename_author(id, new_author) - db.set_sort_field_for_author(id, unicode(new_sort), - commit=False, notify=False) - db.commit() - self.library_view.model().refresh() - self.tags_view.recount() - - def drag_drop_finished(self, ids): - self.library_view.model().refresh_ids(ids) - -# }}} - -class TagBrowserWidget(QWidget): # {{{ - - def __init__(self, parent): - QWidget.__init__(self, parent) - self.parent = parent - self._layout = QVBoxLayout() - self.setLayout(self._layout) - self._layout.setContentsMargins(0,0,0,0) - - # Set up the find box & button - search_layout = QHBoxLayout() - self._layout.addLayout(search_layout) - self.item_search = HistoryLineEdit(parent) - try: - self.item_search.lineEdit().setPlaceholderText( - _('Find item in tag browser')) - except: - pass # Using Qt < 4.7 - self.item_search.setToolTip(_( - 'Search for items. This is a "contains" search; items containing the\n' - 'text anywhere in the name will be found. You can limit the search\n' - 'to particular categories using syntax similar to search. For example,\n' - 'tags:foo will find foo in any tag, but not in authors etc. Entering\n' - '*foo will filter all categories at once, showing only those items\n' - 'containing the text "foo"')) - search_layout.addWidget(self.item_search) - # Not sure if the shortcut should be translatable ... - sc = QShortcut(QKeySequence(_('ALT+f')), parent) - sc.connect(sc, SIGNAL('activated()'), self.set_focus_to_find_box) - - self.search_button = QToolButton() - self.search_button.setText(_('F&ind')) - self.search_button.setToolTip(_('Find the first/next matching item')) - search_layout.addWidget(self.search_button) - - self.expand_button = QToolButton() - self.expand_button.setText('-') - self.expand_button.setToolTip(_('Collapse all categories')) - search_layout.addWidget(self.expand_button) - search_layout.setStretch(0, 10) - search_layout.setStretch(1, 1) - search_layout.setStretch(2, 1) - - self.current_find_position = None - self.search_button.clicked.connect(self.find) - self.item_search.initialize('tag_browser_search') - self.item_search.lineEdit().returnPressed.connect(self.do_find) - self.item_search.lineEdit().textEdited.connect(self.find_text_changed) - self.item_search.activated[QString].connect(self.do_find) - self.item_search.completer().setCaseSensitivity(Qt.CaseSensitive) - - parent.tags_view = TagsView(parent) - self.tags_view = parent.tags_view - self.expand_button.clicked.connect(self.tags_view.collapseAll) - self._layout.addWidget(parent.tags_view) - - # Now the floating 'not found' box - l = QLabel(self.tags_view) - self.not_found_label = l - l.setFrameStyle(QFrame.StyledPanel) - l.setAutoFillBackground(True) - l.setText('

'+_('No More Matches.

Click Find again to go to first match')) - l.setAlignment(Qt.AlignVCenter) - l.setWordWrap(True) - l.resize(l.sizeHint()) - l.move(10,20) - l.setVisible(False) - self.not_found_label_timer = QTimer() - self.not_found_label_timer.setSingleShot(True) - self.not_found_label_timer.timeout.connect(self.not_found_label_timer_event, - type=Qt.QueuedConnection) - - parent.sort_by = QComboBox(parent) - # Must be in the same order as db2.CATEGORY_SORTS - for x in (_('Sort by name'), _('Sort by popularity'), - _('Sort by average rating')): - parent.sort_by.addItem(x) - parent.sort_by.setToolTip( - _('Set the sort order for entries in the Tag Browser')) - parent.sort_by.setStatusTip(parent.sort_by.toolTip()) - parent.sort_by.setCurrentIndex(0) - self._layout.addWidget(parent.sort_by) - - # Must be in the same order as db2.MATCH_TYPE - parent.tag_match = QComboBox(parent) - for x in (_('Match any'), _('Match all')): - parent.tag_match.addItem(x) - parent.tag_match.setCurrentIndex(0) - self._layout.addWidget(parent.tag_match) - parent.tag_match.setToolTip( - _('When selecting multiple entries in the Tag Browser ' - 'match any or all of them')) - parent.tag_match.setStatusTip(parent.tag_match.toolTip()) - - - l = parent.manage_items_button = QPushButton(self) - l.setStyleSheet('QPushButton {text-align: left; }') - l.setText(_('Manage authors, tags, etc')) - l.setToolTip(_('All of these category_managers are available by right-clicking ' - 'on items in the tag browser above')) - l.m = QMenu() - l.setMenu(l.m) - self._layout.addWidget(l) - - # self.leak_test_timer = QTimer(self) - # self.leak_test_timer.timeout.connect(self.test_for_leak) - # self.leak_test_timer.start(5000) - - def set_pane_is_visible(self, to_what): - self.tags_view.set_pane_is_visible(to_what) - - def find_text_changed(self, str): - self.current_find_position = None - - def set_focus_to_find_box(self): - self.item_search.setFocus() - self.item_search.lineEdit().selectAll() - - def do_find(self, str=None): - self.current_find_position = None - self.find() - - def find(self): - model = self.tags_view.model() - model.clear_boxed() - txt = unicode(self.item_search.currentText()).strip() - - if txt.startswith('*'): - self.tags_view.set_new_model(filter_categories_by=txt[1:]) - self.current_find_position = None - return - if model.get_filter_categories_by(): - self.tags_view.set_new_model(filter_categories_by=None) - self.current_find_position = None - model = self.tags_view.model() - - if not txt: - return - - self.item_search.lineEdit().blockSignals(True) - self.search_button.setFocus(True) - self.item_search.lineEdit().blockSignals(False) - - key = None - colon = txt.rfind(':') if len(txt) > 2 else 0 - if colon > 0: - key = self.parent.library_view.model().db.\ - field_metadata.search_term_to_field_key(txt[:colon]) - txt = txt[colon+1:] - - self.current_find_position = \ - model.find_item_node(key, txt, self.current_find_position) - if self.current_find_position: - model.show_item_at_path(self.current_find_position, box=True) - elif self.item_search.text(): - self.not_found_label.setVisible(True) - if self.tags_view.verticalScrollBar().isVisible(): - sbw = self.tags_view.verticalScrollBar().width() - else: - sbw = 0 - width = self.width() - 8 - sbw - height = self.not_found_label.heightForWidth(width) + 20 - self.not_found_label.resize(width, height) - self.not_found_label.move(4, 10) - self.not_found_label_timer.start(2000) - - def not_found_label_timer_event(self): - self.not_found_label.setVisible(False) - - def test_for_leak(self): - from calibre.utils.mem import memory - import gc - before = memory() - self.tags_view.recount() - for i in xrange(3): gc.collect() - print 'Used memory:', memory(before)/(1024.), 'KB' - -# }}} - diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index cf9f6ee610..5007d343ce 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -39,7 +39,7 @@ from calibre.gui2.jobs import JobManager, JobsDialog, JobsButton from calibre.gui2.init import LibraryViewMixin, LayoutMixin from calibre.gui2.search_box import SearchBoxMixin, SavedSearchBoxMixin from calibre.gui2.search_restriction_mixin import SearchRestrictionMixin -from calibre.gui2.tag_view import TagBrowserMixin +from calibre.gui2.tag_browser.ui import TagBrowserMixin class Listener(Thread): # {{{ diff --git a/src/calibre/gui2/update.py b/src/calibre/gui2/update.py index d0399c6cc1..9d4d4def75 100644 --- a/src/calibre/gui2/update.py +++ b/src/calibre/gui2/update.py @@ -72,9 +72,7 @@ class UpdateNotification(QDialog): self.label = QLabel(('

'+ _('%s has been updated to version %s. ' 'See the new features.') + '

'+_('Update only if one of the ' - 'new features or bug fixes is important to you. ' - 'If the current version works well for you, there is no need to update.'))%( + '">new features.'))%( __appname__, calibre_version)) self.label.setOpenExternalLinks(True) self.label.setWordWrap(True)