From 278f3d8127193be97c3cf8d19c12f8a8da3a60fe Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 25 Jun 2011 09:23:48 -0600 Subject: [PATCH 1/9] Remove the delete library functionality from calibre --- src/calibre/gui2/actions/choose_library.py | 25 ++++++++-------------- 1 file changed, 9 insertions(+), 16 deletions(-) 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' From 80025fe5d54532a248ccb8f436a535e5e12eaf34 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 25 Jun 2011 10:20:49 -0600 Subject: [PATCH 2/9] Split up Tag Browser code --- src/calibre/gui2/init.py | 2 +- src/calibre/gui2/tag_browser/__init__.py | 11 + .../{tag_view.py => tag_browser/model.py} | 1041 +---------------- src/calibre/gui2/tag_browser/ui.py | 458 ++++++++ src/calibre/gui2/tag_browser/view.py | 578 +++++++++ src/calibre/gui2/ui.py | 2 +- 6 files changed, 1067 insertions(+), 1025 deletions(-) create mode 100644 src/calibre/gui2/tag_browser/__init__.py rename src/calibre/gui2/{tag_view.py => tag_browser/model.py} (54%) create mode 100644 src/calibre/gui2/tag_browser/ui.py create mode 100644 src/calibre/gui2/tag_browser/view.py 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_view.py b/src/calibre/gui2/tag_browser/model.py similarity index 54% rename from src/calibre/gui2/tag_view.py rename to src/calibre/gui2/tag_browser/model.py index 21029da68c..7f02518b80 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_browser/model.py @@ -1,596 +1,30 @@ -#!/usr/bin/env python +#!/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__ = '2008, Kovid Goyal kovid@kovidgoyal.net' +__copyright__ = '2011, Kovid Goyal ' __docformat__ = 'restructuredtext en' -''' -Browsing book collection by tags. -''' +import traceback, cPickle, copy +from itertools import repeat, izip -import traceback, copy, cPickle +from PyQt4.Qt import (QAbstractItemModel, QIcon, QVariant, QFont, Qt, + QMimeData, QModelIndex, QTreeView) -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.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.utils.search_query_parser import saved_searches -from calibre.utils.formatter import eval_formatter -from calibre.gui2 import error_dialog, question_dialog +from calibre.library.field_metadata import TagsIcons, category_icon_map 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() - - # }}} +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} -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): # {{{ @@ -863,6 +297,7 @@ class TagsModel(QAbstractItemModel): # {{{ self.root_item.break_cycles() self.db = self.root_item = None + # Drag'n Drop {{{ def mimeTypes(self): return ["application/calibre+from_library", 'application/calibre+from_tag_browser'] @@ -1130,6 +565,7 @@ class TagsModel(QAbstractItemModel): # {{{ 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 @@ -1198,7 +634,7 @@ class TagsModel(QAbstractItemModel): # {{{ Here to trap usages of refresh in the old architecture. Can eventually be removed. ''' - print 'TagsModel: refresh called!' + print ('TagsModel: refresh called!') traceback.print_stack() return False @@ -1209,7 +645,7 @@ class TagsModel(QAbstractItemModel): # {{{ sort_by = config['sort_tags_by'] if data is None: - print '_create_node_tree: no data!' + print ('_create_node_tree: no data!') traceback.print_stack() return @@ -1868,444 +1304,3 @@ class TagsModel(QAbstractItemModel): # {{{ # }}} -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/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..79ab1448bf --- /dev/null +++ b/src/calibre/gui2/tag_browser/view.py @@ -0,0 +1,578 @@ +#!/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, traceback +from functools import partial + +from PyQt4.Qt import (QItemDelegate, Qt, QTreeView, pyqtSignal, QSize, QIcon, + QApplication, QMenu, QPoint) + +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.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() + + # }}} + +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) + # }}} + + 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): # {{{ From 4e1cafbce9de6a8dfa50095d6cdf5c140f22a619 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 25 Jun 2011 11:31:55 -0600 Subject: [PATCH 3/9] Tag Browser: Replace the use of internalPointer() with internalId() --- src/calibre/gui2/tag_browser/model.py | 66 +++++++++++++++++---------- src/calibre/gui2/tag_browser/view.py | 8 ++-- 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/src/calibre/gui2/tag_browser/model.py b/src/calibre/gui2/tag_browser/model.py index 7f02518b80..16beefc995 100644 --- a/src/calibre/gui2/tag_browser/model.py +++ b/src/calibre/gui2/tag_browser/model.py @@ -25,7 +25,6 @@ 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} - class TagTreeItem(object): # {{{ CATEGORY = 0 @@ -201,6 +200,7 @@ class TagsModel(QAbstractItemModel): # {{{ filter_categories_by=None, collapse_model='disable', state_map={}): QAbstractItemModel.__init__(self, parent) + self.node_map = {} # must do this here because 'QPixmap: Must construct a QApplication # before a QPaintDevice'. The ':' at the end avoids polluting either of @@ -228,7 +228,7 @@ class TagsModel(QAbstractItemModel): # {{{ 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.root_item = self.create_node(icon_map=self.icon_state_map) self.category_nodes = [] last_category_node = None @@ -261,7 +261,7 @@ class TagsModel(QAbstractItemModel): # {{{ for i,p in enumerate(path_parts): path += p if path not in category_node_map: - node = TagTreeItem(parent=last_category_node, + 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, @@ -282,7 +282,7 @@ class TagsModel(QAbstractItemModel): # {{{ tree_root = tree_root[p] path += '.' else: - node = TagTreeItem(parent=self.root_item, + node = self.create_node(parent=self.root_item, data=self.categories[key], category_icon=self.category_icon_map[key], tooltip=tt, category_key=key, @@ -296,6 +296,9 @@ class TagsModel(QAbstractItemModel): # {{{ def break_cycles(self): self.root_item.break_cycles() self.db = self.root_item = None + self.node_map = {} + #traceback.print_stack() + #print # Drag'n Drop {{{ def mimeTypes(self): @@ -307,7 +310,7 @@ class TagsModel(QAbstractItemModel): # {{{ for idx in indexes: if idx.isValid(): # get some useful serializable data - node = idx.internalPointer() + 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) @@ -340,7 +343,7 @@ class TagsModel(QAbstractItemModel): # {{{ def do_drop_from_tag_browser(self, md, action, row, column, parent): if not parent.isValid(): return False - dest = parent.internalPointer() + dest = self.get_node(parent) if dest.type != TagTreeItem.CATEGORY: return False if not md.hasFormat('application/calibre+from_tag_browser'): @@ -418,7 +421,8 @@ class TagsModel(QAbstractItemModel): # {{{ 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()) + is_uc, dest_key, + self.get_node(node)) self.db.prefs.set('user_categories', user_cats) self.tags_view.recount() @@ -435,7 +439,7 @@ class TagsModel(QAbstractItemModel): # {{{ path[-1] = p - 1 idx = m.index_for_path(path) self.tags_view.setExpanded(idx, True) - if idx.internalPointer().type == TagTreeItem.TAG: + if self.get_node(idx).type == TagTreeItem.TAG: m.show_item_at_index(idx, box=True) else: m.show_item_at_index(idx) @@ -638,6 +642,20 @@ class TagsModel(QAbstractItemModel): # {{{ 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 _create_node_tree(self, data, state_map): ''' Called by __init__. Do not directly call this method. @@ -666,7 +684,7 @@ class TagsModel(QAbstractItemModel): # {{{ def process_one_node(category, state_map): # {{{ collapse_letter = None category_index = self.createIndex(category.row(), 0, category) - category_node = category_index.internalPointer() + category_node = category key = category_node.category_key if key not in data: return @@ -733,7 +751,7 @@ class TagsModel(QAbstractItemModel): # {{{ 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, + sub_cat = self.create_node(parent=category, data = name, tooltip = None, temporary=True, category_icon = category_node.icon, category_key=category_node.category_key, @@ -744,7 +762,7 @@ class TagsModel(QAbstractItemModel): # {{{ cl = cl_list[idx] if cl != collapse_letter: collapse_letter = cl - sub_cat = TagTreeItem(parent=category, + sub_cat = self.create_node(parent=category, data = collapse_letter, category_icon = category_node.icon, tooltip = None, temporary=True, @@ -767,7 +785,7 @@ class TagsModel(QAbstractItemModel): # {{{ 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, + 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 @@ -803,7 +821,7 @@ class TagsModel(QAbstractItemModel): # {{{ '5state' if t.category != 'search' else '3state' t.name = comp self.beginInsertRows(category_index, 999998, 999999) - node_parent = TagTreeItem(parent=node_parent, data=t, + 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 self.endInsertRows() @@ -837,7 +855,7 @@ class TagsModel(QAbstractItemModel): # {{{ def data(self, index, role): if not index.isValid(): return NONE - item = index.internalPointer() + item = self.get_node(index) return item.data(role) def setData(self, index, value, role=Qt.EditRole): @@ -852,7 +870,7 @@ class TagsModel(QAbstractItemModel): # {{{ error_dialog(self.tags_view, _('Item is blank'), _('An item cannot be set to nothing. Delete it instead.')).exec_() return False - item = index.internalPointer() + item = self.get_node(index) if item.type == TagTreeItem.CATEGORY and item.category_key.startswith('@'): if val.find('.') >= 0: error_dialog(self.tags_view, _('Rename user category'), @@ -1022,7 +1040,7 @@ class TagsModel(QAbstractItemModel): # {{{ if not parent.isValid(): parent_item = self.root_item else: - parent_item = parent.internalPointer() + parent_item = self.get_node(parent) try: child_item = parent_item.children[row] @@ -1036,7 +1054,7 @@ class TagsModel(QAbstractItemModel): # {{{ if not index.isValid(): return QModelIndex() - child_item = index.internalPointer() + 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: @@ -1052,7 +1070,7 @@ class TagsModel(QAbstractItemModel): # {{{ if not parent.isValid(): parent_item = self.root_item else: - parent_item = parent.internalPointer() + parent_item = self.get_node(parent) return len(parent_item.children) @@ -1083,7 +1101,7 @@ class TagsModel(QAbstractItemModel): # {{{ set_to: None => advance the state, otherwise a value from TAG_SEARCH_STATES ''' if not index.isValid(): return False - item = index.internalPointer() + item = self.get_node(index) item.toggle(set_to=set_to) if exclusive: self.reset_all_states(except_=item.tag) @@ -1212,10 +1230,10 @@ class TagsModel(QAbstractItemModel): # {{{ return False if path[depth] > start_path[depth]: start_path = path - my_key = category_index.internalPointer().category_key + 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 = tag_index.internalPointer() + tag_item = self.get_node(tag_index) if tag_item.type == TagTreeItem.CATEGORY: if process_level(depth+1, tag_index, start_path): return True @@ -1240,7 +1258,7 @@ class TagsModel(QAbstractItemModel): # {{{ for i in xrange(self.rowCount(parent)): idx = self.index(i, 0, parent) - node = idx.internalPointer() + node = self.get_node(idx) if node.type == TagTreeItem.CATEGORY: ckey = node.category_key if strcmp(ckey, key) == 0: @@ -1269,7 +1287,7 @@ class TagsModel(QAbstractItemModel): # {{{ self.tags_view.scrollTo(idx, position) self.tags_view.setCurrentIndex(idx) if box: - tag_item = idx.internalPointer() + tag_item = self.get_node(idx) tag_item.boxed = True self.dataChanged.emit(idx, idx) @@ -1287,7 +1305,7 @@ class TagsModel(QAbstractItemModel): # {{{ 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() + tag_item = self.get_node(tag_index) if tag_item.boxed: tag_item.boxed = False self.dataChanged.emit(tag_index, tag_index) diff --git a/src/calibre/gui2/tag_browser/view.py b/src/calibre/gui2/tag_browser/view.py index 79ab1448bf..0cafcd2b63 100644 --- a/src/calibre/gui2/tag_browser/view.py +++ b/src/calibre/gui2/tag_browser/view.py @@ -22,7 +22,7 @@ from calibre.utils.icu import sort_key class TagDelegate(QItemDelegate): # {{{ def paint(self, painter, option, index): - item = index.internalPointer() + item = index.data(Qt.UserRole).toPyObject() if item.type != TagTreeItem.TAG: QItemDelegate.paint(self, painter, option, index) return @@ -301,7 +301,7 @@ class TagsView(QTreeView): # {{{ self.context_menu = QMenu(self) if index.isValid(): - item = index.internalPointer() + item = index.data(Qt.UserRole).toPyObject() tag = None if item.type == TagTreeItem.TAG: @@ -486,7 +486,7 @@ class TagsView(QTreeView): # {{{ if not index.isValid(): return src_is_tb = event.mimeData().hasFormat('application/calibre+from_tag_browser') - item = index.internalPointer() + 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) @@ -520,7 +520,7 @@ class TagsView(QTreeView): # {{{ self.model().clear_state() def is_visible(self, idx): - item = idx.internalPointer() + item = idx.data(Qt.UserRole).toPyObject() if getattr(item, 'type', None) == TagTreeItem.TAG: idx = idx.parent() return self.isExpanded(idx) From 3873c2ad71ee6ea17ab393aebe9bd88e80daaf7a Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 25 Jun 2011 11:39:40 -0600 Subject: [PATCH 4/9] ... --- src/calibre/gui2/update.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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) From bc48b5117a185a61217d8a847ab9ed78f421eb3f Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 25 Jun 2011 12:17:13 -0600 Subject: [PATCH 5/9] Remove pointless beginInsertRows/endInsertRows calls --- src/calibre/gui2/tag_browser/model.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/calibre/gui2/tag_browser/model.py b/src/calibre/gui2/tag_browser/model.py index 16beefc995..4022db4fd8 100644 --- a/src/calibre/gui2/tag_browser/model.py +++ b/src/calibre/gui2/tag_browser/model.py @@ -683,7 +683,6 @@ class TagsModel(QAbstractItemModel): # {{{ def process_one_node(category, state_map): # {{{ collapse_letter = None - category_index = self.createIndex(category.row(), 0, category) category_node = category key = category_node.category_key if key not in data: @@ -750,14 +749,12 @@ class TagsModel(QAbstractItemModel): # {{{ 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 = 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 - self.endInsertRows() else: # by 'first letter' cl = cl_list[idx] if cl != collapse_letter: @@ -784,13 +781,11 @@ class TagsModel(QAbstractItemModel): # {{{ 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 = 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 - self.endInsertRows() else: for i,comp in enumerate(components): if i == 0: @@ -820,11 +815,9 @@ class TagsModel(QAbstractItemModel): # {{{ t.is_hierarchical = \ '5state' if t.category != 'search' else '3state' t.name = comp - self.beginInsertRows(category_index, 999998, 999999) 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 - self.endInsertRows() # This id_set must not be None node_parent.id_set |= tag.id_set return From 4931d2d41f40842016a22f4938f63d00b8aac5bf Mon Sep 17 00:00:00 2001 From: John Schember Date: Sat, 25 Jun 2011 16:23:59 -0400 Subject: [PATCH 6/9] Fix Bug #801888: PalmDoc compression cannot compress empty string. --- src/calibre/ebooks/compression/palmdoc.py | 2 ++ 1 file changed, 2 insertions(+) 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(): From 97b723de07a666b2857248058b22d8c217741f7d Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 25 Jun 2011 19:38:12 -0600 Subject: [PATCH 7/9] Refactor Tag Browser to separate model and view. WARNING: All advanced functionality in the Tag Browser is currently broken --- src/calibre/gui2/tag_browser/model.py | 897 +++++++++++++------------- src/calibre/gui2/tag_browser/view.py | 144 +++-- 2 files changed, 510 insertions(+), 531 deletions(-) diff --git a/src/calibre/gui2/tag_browser/model.py b/src/calibre/gui2/tag_browser/model.py index 4022db4fd8..13af84a79e 100644 --- a/src/calibre/gui2/tag_browser/model.py +++ b/src/calibre/gui2/tag_browser/model.py @@ -2,16 +2,17 @@ # 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, izip +from itertools import repeat from PyQt4.Qt import (QAbstractItemModel, QIcon, QVariant, QFont, Qt, - QMimeData, QModelIndex, QTreeView) + QMimeData, QModelIndex, pyqtSignal) from calibre.gui2 import NONE, gprefs, config, error_dialog from calibre.library.database2 import Tag @@ -25,6 +26,15 @@ 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 @@ -41,16 +51,15 @@ class TagTreeItem(object): # {{{ 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, @@ -60,27 +69,21 @@ class TagTreeItem(object): # {{{ elif self.type == self.TAG: self.icon_state_map[0] = QVariant(data.icon) self.tag = data - if tooltip: - self.tooltip = tooltip + ' ' - else: - self.tooltip = '' + + self.tooltip = (tooltip + ' ') if tooltip else '' 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 + 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(self.children) - return 'TAG:'+self.tag.name + 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: @@ -110,7 +113,7 @@ class TagTreeItem(object): # {{{ return self.icon_state_map[self.tag.state] return self.icon if role == Qt.FontRole: - return self.bold_font + return bf() if role == Qt.ToolTipRole and self.tooltip is not None: return QVariant(self.tooltip) return NONE @@ -195,41 +198,81 @@ class TagTreeItem(object): # {{{ 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={}): + 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 = {} - - # 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 '@') + 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.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 + 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 + + def reread_collapse_model(self, state_map): + if gprefs['tags_browser_collapse_at'] == 0: + self.collapse_model = 'disable' + else: + self.collapse_model = gprefs['tags_browser_partition_method'] + 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 = db.prefs.get('grouped_search_terms', {}) - self.root_item = self.create_node(icon_map=self.icon_state_map) - self.category_nodes = [] + gst = self.db.prefs.get('grouped_search_terms', {}) last_category_node = None category_node_map = {} @@ -293,373 +336,7 @@ class TagsModel(QAbstractItemModel): # {{{ 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 - self.node_map = {} - #traceback.print_stack() - #print - - # 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', {}) - 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, - self.get_node(node)) - - 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 self.get_node(idx).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(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 _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: @@ -826,16 +503,338 @@ class TagsModel(QAbstractItemModel): # {{{ 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 + # 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): @@ -853,20 +852,19 @@ class TagsModel(QAbstractItemModel): # {{{ def setData(self, index, value, role=Qt.EditRole): if not index.isValid(): - return NONE + 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 - path = index.model().path_for_index(index) val = unicode(value.toString()).strip() if not val: - error_dialog(self.tags_view, _('Item is blank'), + 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.tags_view, _('Rename user category'), + error_dialog(self.parent(), _('Rename user category'), _('You cannot use periods in the name when ' 'renaming user categories'), show=True) return False @@ -886,7 +884,7 @@ class TagsModel(QAbstractItemModel): # {{{ 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'), + error_dialog(self.parent(), _('Rename user category'), _('The name %s is already used')%nkey, show=True) return False user_cats[nkey] = user_cats[ckey] @@ -895,16 +893,12 @@ class TagsModel(QAbstractItemModel): # {{{ 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'), + 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.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) + self.user_categories_edited.emit(user_cats, nkey) # Does a refresh return True key = item.tag.category @@ -914,17 +908,17 @@ class TagsModel(QAbstractItemModel): # {{{ return False if key == 'authors': if val.find('&') >= 0: - error_dialog(self.tags_view, _('Invalid author name'), + 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.tags_view, _('Duplicate search name'), + 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.tags_view.search_item_renamed.emit() # Does a refresh + self.search_item_renamed.emit() # Does a refresh else: if key == 'series': self.db.rename_series(item.tag.id, val) @@ -937,18 +931,17 @@ class TagsModel(QAbstractItemModel): # {{{ 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() + self.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) + 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 (recount or set_new_model) + redisplay the tree as appropriate. ''' user_cats = self.db.prefs.get('user_categories', {}) for k in user_cats.keys(): @@ -965,7 +958,7 @@ class TagsModel(QAbstractItemModel): # {{{ ''' 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) + tree as appropriate. ''' user_cats = self.db.prefs.get('user_categories', {}) for cat in user_cats.keys(): @@ -1262,27 +1255,10 @@ class TagsModel(QAbstractItemModel): # {{{ 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 = self.get_node(idx) - tag_item.boxed = True - self.dataChanged.emit(idx, idx) + def set_boxed(self, idx): + tag_item = self.get_node(idx) + tag_item.boxed = True + self.dataChanged.emit(idx, idx) def clear_boxed(self): ''' @@ -1310,8 +1286,5 @@ class TagsModel(QAbstractItemModel): # {{{ 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 - # }}} diff --git a/src/calibre/gui2/tag_browser/view.py b/src/calibre/gui2/tag_browser/view.py index 0cafcd2b63..f8ae6e939f 100644 --- a/src/calibre/gui2/tag_browser/view.py +++ b/src/calibre/gui2/tag_browser/view.py @@ -7,11 +7,12 @@ __license__ = 'GPL v3' __copyright__ = '2011, Kovid Goyal ' __docformat__ = 'restructuredtext en' -import cPickle, traceback +import cPickle from functools import partial +from itertools import izip from PyQt4.Qt import (QItemDelegate, Qt, QTreeView, pyqtSignal, QSize, QIcon, - QApplication, QMenu, QPoint) + QApplication, QMenu, QPoint, QModelIndex) from calibre.gui2.tag_browser.model import (TagTreeItem, TAG_SEARCH_STATES, TagsModel) @@ -70,6 +71,7 @@ class TagsView(QTreeView): # {{{ 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) @@ -90,14 +92,30 @@ class TagsView(QTreeView): # {{{ 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')) + 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 def set_pane_is_visible(self, to_what): pv = self.pane_is_visible @@ -105,40 +123,26 @@ class TagsView(QTreeView): # {{{ 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): - 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()) + self._model.reread_collapse_parameters(self.get_state()[1]) 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) + self._model.set_database(db) - 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.pane_is_visible = True # because TagsModel.set_database 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'] @@ -164,6 +168,12 @@ class TagsView(QTreeView): # {{{ 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 @@ -179,11 +189,8 @@ class TagsView(QTreeView): # {{{ pass def set_search_restriction(self, s): - if s: - self.search_restriction = s - else: - self.search_restriction = None - self.set_new_model() + 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 @@ -271,6 +278,7 @@ class TagsView(QTreeView): # {{{ self.author_sort_edit.emit(self, index) return + reset_filter_categories = True if action == 'hide': self.hidden_categories.add(category) elif action == 'show': @@ -279,12 +287,14 @@ class TagsView(QTreeView): # {{{ changed = self.collapse_model != category self.collapse_model = category if changed: - self.set_new_model(self._model.get_filter_categories_by()) + 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)) - self.set_new_model() + if reset_filter_categories: + self._model.filter_categories_by = None + self._model.rebuild_node_tree() except: return @@ -537,11 +547,31 @@ class TagsView(QTreeView): # {{{ 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) + 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._model.show_item_at_path(path) + 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): ''' @@ -549,30 +579,6 @@ class TagsView(QTreeView): # {{{ ''' 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) # }}} From a1546c62dbb904891c63505aea98064bc6ab482f Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 25 Jun 2011 19:43:40 -0600 Subject: [PATCH 8/9] Tag Browser: Read collapse model on startup --- src/calibre/gui2/tag_browser/model.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/calibre/gui2/tag_browser/model.py b/src/calibre/gui2/tag_browser/model.py index 13af84a79e..a80c3ceca2 100644 --- a/src/calibre/gui2/tag_browser/model.py +++ b/src/calibre/gui2/tag_browser/model.py @@ -224,13 +224,15 @@ class TagsModel(QAbstractItemModel): # {{{ 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): + 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'] - self.rebuild_node_tree(state_map) + if rebuild: + self.rebuild_node_tree(state_map) def set_search_restriction(self, s): self.search_restriction = s From 34b501a0f3ea2f1ec63826d5f8236dcffb7bf048 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 25 Jun 2011 19:54:55 -0600 Subject: [PATCH 9/9] ... --- src/calibre/gui2/tag_browser/view.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/calibre/gui2/tag_browser/view.py b/src/calibre/gui2/tag_browser/view.py index f8ae6e939f..4ff1227a6a 100644 --- a/src/calibre/gui2/tag_browser/view.py +++ b/src/calibre/gui2/tag_browser/view.py @@ -117,6 +117,10 @@ class TagsView(QTreeView): # {{{ 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 @@ -285,7 +289,7 @@ class TagsView(QTreeView): # {{{ self.hidden_categories.discard(category) elif action == 'categorization': changed = self.collapse_model != category - self.collapse_model = category + self._model.collapse_model = category if changed: reset_filter_categories = False gprefs['tags_browser_partition_method'] = category