From 26f5642e025752402acaf9102975ffbbe73df946 Mon Sep 17 00:00:00 2001 From: Charles Haley <> Date: Wed, 2 Jun 2010 13:19:28 +0100 Subject: [PATCH] 1. added context menu on tags pane. 2. added saved search editor 3l added tags editor (a cut-down version of the original) 4. added access to editors from context menu. --- src/calibre/gui2/__init__.py | 3 +- .../gui2/dialogs/saved_search_editor.py | 86 +++++++++ .../gui2/dialogs/saved_search_editor.ui | 181 ++++++++++++++++++ src/calibre/gui2/dialogs/tag_categories.py | 9 +- src/calibre/gui2/dialogs/tag_list_editor.py | 55 ++++++ src/calibre/gui2/dialogs/tag_list_editor.ui | 132 +++++++++++++ src/calibre/gui2/tag_view.py | 130 +++++++++++-- src/calibre/gui2/ui.py | 29 ++- 8 files changed, 597 insertions(+), 28 deletions(-) create mode 100644 src/calibre/gui2/dialogs/saved_search_editor.py create mode 100644 src/calibre/gui2/dialogs/saved_search_editor.ui create mode 100644 src/calibre/gui2/dialogs/tag_list_editor.py create mode 100644 src/calibre/gui2/dialogs/tag_list_editor.ui diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py index 07a5e877b1..bddefe97f8 100644 --- a/src/calibre/gui2/__init__.py +++ b/src/calibre/gui2/__init__.py @@ -97,7 +97,8 @@ def _config(): help=_('Overwrite author and title with new metadata')) c.add_opt('enforce_cpu_limit', default=True, help=_('Limit max simultaneous jobs to number of CPUs')) - + c.add_opt('tag_browser_hidden_categories', default=set(), + help=_('tag browser categories not to display')) return ConfigProxy(c) config = _config() diff --git a/src/calibre/gui2/dialogs/saved_search_editor.py b/src/calibre/gui2/dialogs/saved_search_editor.py new file mode 100644 index 0000000000..a9382201b9 --- /dev/null +++ b/src/calibre/gui2/dialogs/saved_search_editor.py @@ -0,0 +1,86 @@ +__license__ = 'GPL v3' + +__copyright__ = '2008, Kovid Goyal ' + + +from PyQt4.QtCore import SIGNAL, Qt +from PyQt4.QtGui import QDialog, QIcon, QListWidgetItem + +from calibre.gui2.dialogs.saved_search_editor_ui import Ui_SavedSearchEditor +from calibre.utils.config import prefs +from calibre.utils.search_query_parser import saved_searches +from calibre.gui2.dialogs.confirm_delete import confirm +from calibre.constants import islinux + +class SavedSearchEditor(QDialog, Ui_SavedSearchEditor): + + def __init__(self, window, initial_search=None): + QDialog.__init__(self, window) + Ui_SavedSearchEditor.__init__(self) + self.setupUi(self) + + self.connect(self.add_search_button, SIGNAL('clicked()'), self.add_search) + self.connect(self.search_name_box, SIGNAL('currentIndexChanged(int)'), + self.current_index_changed) + self.connect(self.delete_search_button, SIGNAL('clicked()'), self.del_search) + + self.current_search_name = None + self.searches = {} + self.searches_to_delete = [] + for name in saved_searches.names(): + self.searches[name] = saved_searches.lookup(name) + + self.populate_search_list() + if initial_search is not None and initial_search in self.searches: + self.select_search(initial_search) + + def populate_search_list(self): + self.search_name_box.clear() + for name in sorted(self.searches.keys()): + self.search_name_box.addItem(name) + + def add_search(self): + search_name = unicode(self.input_box.text()).strip() + if search_name == '': + return False + if search_name not in self.searches: + self.searches[search_name] = '' + self.populate_search_list() + self.select_search(search_name) + else: + self.select_search(search_name) + return True + + def del_search(self): + if self.current_search_name is not None: + if not confirm('

'+_('The current saved search will be ' + 'permanently deleted. Are you sure?') + +'

', 'saved_search_editor_delete', self): + return + del self.searches[self.current_search_name] + self.searches_to_delete.append(self.current_search_name) + self.current_search_name = None + self.search_name_box.removeItem(self.search_name_box.currentIndex()) + + def select_search(self, name): + self.search_name_box.setCurrentIndex(self.search_name_box.findText(name)) + + def current_index_changed(self, idx): + if self.current_search_name: + self.searches[self.current_search_name] = unicode(self.search_text.toPlainText()) + name = unicode(self.search_name_box.itemText(idx)) + if name: + self.current_search_name = name + self.search_text.setPlainText(self.searches[name]) + else: + self.current_search_name = None + self.search_text.setPlainText('') + + def accept(self): + if self.current_search_name: + self.searches[self.current_search_name] = unicode(self.search_text.toPlainText()) + for name in self.searches_to_delete: + saved_searches.delete(name) + for name in self.searches: + saved_searches.add(name, self.searches[name]) + QDialog.accept(self) diff --git a/src/calibre/gui2/dialogs/saved_search_editor.ui b/src/calibre/gui2/dialogs/saved_search_editor.ui new file mode 100644 index 0000000000..3fffff7abf --- /dev/null +++ b/src/calibre/gui2/dialogs/saved_search_editor.ui @@ -0,0 +1,181 @@ + + + SavedSearchEditor + + + + 0 + 0 + 548 + 148 + + + + Tag Editor + + + + :/images/chapters.svg:/images/chapters.svg + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + true + + + + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + Saved Search: + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + search_name_box + + + + + + + + 160 + 0 + + + + + 145 + 0 + + + + Select a saved search to edit + + + false + + + + + + + Delete this selected saved search + + + ... + + + + :/images/minus.svg:/images/minus.svg + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 60 + 0 + + + + Enter a new saved search name. + + + + + + + Add the new saved search + + + ... + + + + :/images/plus.svg:/images/plus.svg + + + + + + + + + + + + + + + + buttonBox + accepted() + SavedSearchEditor + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + SavedSearchEditor + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/calibre/gui2/dialogs/tag_categories.py b/src/calibre/gui2/dialogs/tag_categories.py index fcf517e571..b7d64226ab 100644 --- a/src/calibre/gui2/dialogs/tag_categories.py +++ b/src/calibre/gui2/dialogs/tag_categories.py @@ -24,13 +24,12 @@ class Item: class TagCategories(QDialog, Ui_TagCategories): category_labels_orig = ['', 'authors', 'series', 'publishers', 'tags'] - def __init__(self, window, db, index=None): + def __init__(self, window, db, on_category=None): QDialog.__init__(self, window) Ui_TagCategories.__init__(self) self.setupUi(self) self.db = db - self.index = index self.applied_items = [] cc_icon = QIcon(I('column.svg')) @@ -102,8 +101,10 @@ class TagCategories(QDialog, Ui_TagCategories): self.connect(self.applied_items_box, SIGNAL('itemActivated(QListWidgetItem*)'), self.unapply_tags) self.populate_category_list() - return - self.select_category(0) + if on_category is not None: + l = self.category_box.findText(on_category) + if l >= 0: + self.category_box.setCurrentIndex(l) def make_list_widget(self, item): n = item.name if item.exists else item.name + _(' (not on any book)') diff --git a/src/calibre/gui2/dialogs/tag_list_editor.py b/src/calibre/gui2/dialogs/tag_list_editor.py new file mode 100644 index 0000000000..102dc28f48 --- /dev/null +++ b/src/calibre/gui2/dialogs/tag_list_editor.py @@ -0,0 +1,55 @@ +__license__ = 'GPL v3' +__copyright__ = '2008, Kovid Goyal ' +from PyQt4.QtCore import SIGNAL +from PyQt4.QtGui import QDialog + +from calibre.gui2.dialogs.tag_list_editor_ui import Ui_TagListEditor +from calibre.gui2 import question_dialog, error_dialog + +class TagListEditor(QDialog, Ui_TagListEditor): + + def tag_cmp(self, x, y): + return cmp(x.lower(), y.lower()) + + def __init__(self, window, db): + QDialog.__init__(self, window) + Ui_TagListEditor.__init__(self) + self.setupUi(self) + + self.to_delete = [] + self.db = db + all_tags = [tag for tag in self.db.all_tags()] + all_tags = list(set(all_tags)) + all_tags.sort(cmp=self.tag_cmp) + for tag in all_tags: + self.available_tags.addItem(tag) + + self.connect(self.delete_button, SIGNAL('clicked()'), self.delete_tags) + + def delete_tags(self, item=None): + confirms, deletes = [], [] + items = self.available_tags.selectedItems() if item is None else [item] + if not items: + error_dialog(self, 'No tags selected', 'You must select at least one tag from the list of Available tags.').exec_() + return + for item in items: + if self.db.is_tag_used(unicode(item.text())): + confirms.append(item) + else: + deletes.append(item) + if confirms: + ct = ', '.join([unicode(item.text()) for item in confirms]) + if question_dialog(self, _('Are your sure?'), + '

'+_('The following tags are used by one or more books. ' + 'Are you certain you want to delete them?')+'
'+ct): + deletes += confirms + + for item in deletes: + self.to_delete.append(item) + self.available_tags.takeItem(self.available_tags.row(item)) + + def accept(self): + for item in self.to_delete: + self.db.delete_tag(unicode(item.text())) + QDialog.accept(self) + diff --git a/src/calibre/gui2/dialogs/tag_list_editor.ui b/src/calibre/gui2/dialogs/tag_list_editor.ui new file mode 100644 index 0000000000..a7d24b02c0 --- /dev/null +++ b/src/calibre/gui2/dialogs/tag_list_editor.ui @@ -0,0 +1,132 @@ + + + TagListEditor + + + + 0 + 0 + 397 + 335 + + + + Tag Editor + + + + :/images/chapters.svg:/images/chapters.svg + + + + + + + + + + Tags in use + + + available_tags + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Delete tag from database. This will unapply the tag from all books and then remove it from the database. + + + ... + + + + :/images/trash.svg:/images/trash.svg + + + + + + + true + + + QAbstractItemView::MultiSelection + + + QAbstractItemView::SelectRows + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + + buttonBox + accepted() + TagListEditor + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + TagListEditor + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py index 15270e14b1..a2e9620553 100644 --- a/src/calibre/gui2/tag_view.py +++ b/src/calibre/gui2/tag_view.py @@ -8,10 +8,11 @@ Browsing book collection by tags. ''' from itertools import izip +from functools import partial from PyQt4.Qt import Qt, QTreeView, QApplication, pyqtSignal, \ QFont, QSize, QIcon, QPoint, \ - QAbstractItemModel, QVariant, QModelIndex + QAbstractItemModel, QVariant, QModelIndex, QMenu from calibre.gui2 import config, NONE from calibre.utils.config import prefs from calibre.library.field_metadata import TagsIcons @@ -19,9 +20,12 @@ from calibre.utils.search_query_parser import saved_searches class TagsView(QTreeView): # {{{ - need_refresh = pyqtSignal() - restriction_set = pyqtSignal(object) - tags_marked = pyqtSignal(object, object) + need_refresh = pyqtSignal() + restriction_set = pyqtSignal(object) + tags_marked = pyqtSignal(object, object) + user_category_edit = pyqtSignal(object) + tag_list_edit = pyqtSignal() + saved_search_edit = pyqtSignal(object) def __init__(self, *args): QTreeView.__init__(self, *args) @@ -31,13 +35,16 @@ class TagsView(QTreeView): # {{{ self.tag_match = None def set_database(self, db, tag_match, popularity, restriction): - self._model = TagsModel(db, parent=self) + self.hidden_categories = config['tag_browser_hidden_categories'] + self._model = TagsModel(db, parent=self, hidden_columns=self.hidden_categories) self.popularity = popularity self.restriction = restriction self.tag_match = tag_match self.db = db self.setModel(self._model) + self.setContextMenuPolicy(Qt.CustomContextMenu) self.clicked.connect(self.toggle) + self.customContextMenuRequested.connect(self.show_context_menu) self.popularity.setChecked(config['sort_by_popularity']) self.popularity.stateChanged.connect(self.sort_changed) self.restriction.activated[str].connect(self.search_restriction_set) @@ -45,10 +52,6 @@ class TagsView(QTreeView): # {{{ db.add_listener(self.database_changed) self.saved_searches_changed(recount=False) - def create_tag_category(self, name, tag_list): - self._model.create_tag_category(name, tag_list) - self.recount() - def database_changed(self, event, ids): self.need_refresh.emit() @@ -72,12 +75,91 @@ class TagsView(QTreeView): # {{{ self.recount() # Must happen after the emission of the restriction_set signal self.tags_marked.emit(self._model.tokens(), self.match_all) + def mouseReleaseEvent(self, event): + # Swallow everything except leftButton so context menus work correctly + if event.button() == Qt.LeftButton: + QTreeView.mouseReleaseEvent(self, event) + def toggle(self, index): modifiers = int(QApplication.keyboardModifiers()) exclusive = modifiers not in (Qt.CTRL, Qt.SHIFT) if self._model.toggle(index, exclusive): self.tags_marked.emit(self._model.tokens(), self.match_all) + def context_menu_handler(self, action=None, category=None): + if not action: + return + try: + if action == 'manage_tags': + self.tag_list_edit.emit(); + return + if action == 'manage_categories': + self.user_category_edit.emit(category) + return + if action == 'manage_searches': + self.saved_search_edit.emit(category) + return + if action == 'hide': + self.hidden_categories.add(category) + elif action == 'show': + self.hidden_categories.discard(category) + elif action == 'defaults': + self.hidden_categories.clear() + config.set('tag_browser_hidden_categories', self.hidden_categories) + self.set_new_model() + except: + return + + def show_context_menu(self, point): + index = self.indexAt(point) + if not index.isValid(): + return False + item = index.internalPointer() + tag_name = '' + if item.type == TagTreeItem.TAG: + tag_name = item.tag.name + item = item.parent + if item.type == TagTreeItem.CATEGORY: + category = unicode(item.name.toString()) + self.context_menu = QMenu(self) + self.context_menu.addAction(_('Hide column %s') % category, + partial(self.context_menu_handler, action='hide', category=category)) + + if self.hidden_categories: + self.context_menu.addSeparator() + m = self.context_menu.addMenu(_('Show column')) + for col in self.hidden_categories: + m.addAction(col, + partial(self.context_menu_handler, action='show', category=col)) + self.context_menu.addSeparator() + self.context_menu.addAction(_('Restore defaults'), + partial(self.context_menu_handler, action='defaults')) + + self.context_menu.addSeparator() + self.context_menu.addAction(_('Manage Tags'), + partial(self.context_menu_handler, action='manage_tags')) + + if category in prefs['user_categories'].keys(): + self.context_menu.addAction(_('Manage User Categories'), + partial(self.context_menu_handler, action='manage_categories', + category=category)) + else: + self.context_menu.addAction(_('Manage User Categories'), + partial(self.context_menu_handler, action='manage_categories', + category=None)) + + if tag_name in saved_searches.names(): + self.context_menu.addAction(_('Manage Saved Searches'), + partial(self.context_menu_handler, action='manage_searches', + category=tag_name)) + else: + self.context_menu.addAction(_('Manage Saved Searches'), + partial(self.context_menu_handler, action='manage_searches', + category=None)) + + self.context_menu.popup(self.mapToGlobal(point)) + return True; + def clear(self): self.model().clear_state() @@ -110,13 +192,12 @@ class TagsView(QTreeView): # {{{ self.setCurrentIndex(idx) self.scrollTo(idx, QTreeView.PositionAtCenter) - ''' - If the number of user categories changed, or if custom columns have come or gone, - we must rebuild the model. Reason: it is much easier to do that than to reconstruct - the browser tree. - ''' + # If the number of user categories changed, if custom columns have come or + # gone, or if columns have been hidden or restored, we must rebuild the + # model. Reason: it is much easier than reconstructing the browser tree. def set_new_model(self): - self._model = TagsModel(self.db, parent=self) + self._model = TagsModel(self.db, parent=self, + hidden_categories=self.hidden_categories) self.setModel(self._model) # }}} @@ -200,7 +281,7 @@ class TagTreeItem(object): # {{{ class TagsModel(QAbstractItemModel): # {{{ - def __init__(self, db, parent=None): + def __init__(self, db, parent=None, hidden_categories=None): QAbstractItemModel.__init__(self, parent) # must do this here because 'QPixmap: Must construct a QApplication @@ -220,6 +301,7 @@ class TagsModel(QAbstractItemModel): # {{{ self.icon_state_map = [None, QIcon(I('plus.svg')), QIcon(I('minus.svg'))] self.db = db + self.hidden_categories = hidden_categories self.search_restriction = '' self.ignore_next_search = 0 @@ -237,6 +319,8 @@ class TagsModel(QAbstractItemModel): # {{{ data = self.get_node_tree(config['sort_by_popularity']) self.root_item = TagTreeItem() for i, r in enumerate(self.row_map): + if self.categories[i]in self.hidden_categories: + continue if self.db.field_metadata[r]['kind'] != 'user': tt = _('The lookup/search name is "{0}"').format(r) else: @@ -271,12 +355,16 @@ class TagsModel(QAbstractItemModel): # {{{ def refresh(self): data = self.get_node_tree(config['sort_by_popularity']) # get category data + row_index = -1; for i, r in enumerate(self.row_map): - category = self.root_item.children[i] + if self.categories[i] in self.hidden_categories: + continue + row_index += 1 + category = self.root_item.children[row_index] names = [t.tag.name for t in category.children] states = [t.tag.state for t in category.children] state_map = dict(izip(names, states)) - category_index = self.index(i, 0, QModelIndex()) + category_index = self.index(row_index, 0, QModelIndex()) if len(category.children) > 0: self.beginRemoveRows(category_index, 0, len(category.children)-1) @@ -401,10 +489,14 @@ class TagsModel(QAbstractItemModel): # {{{ def tokens(self): ans = [] tags_seen = set() + row_index = -1; for i, key in enumerate(self.row_map): + if self.categories[i] in self.hidden_categories: + continue + row_index += 1 if key.endswith(':'): # User category, so skip it. The tag will be marked in its real category continue - category_item = self.root_item.children[i] + category_item = self.root_item.children[row_index] for tag_item in category_item.children: tag = tag_item.tag if tag.state > 0: diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index 8bc85e7195..da522b2153 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -61,6 +61,8 @@ from calibre.library.database2 import LibraryDatabase2 from calibre.library.caches import CoverCache 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.saved_search_editor import SavedSearchEditor class SaveMenu(QMenu): @@ -537,19 +539,23 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self.cover_cache = CoverCache(self.library_path) self.cover_cache.start() self.library_view.model().cover_cache = self.cover_cache - self.connect(self.edit_categories, SIGNAL('clicked()'), self.do_edit_categories) + self.connect(self.edit_categories, SIGNAL('clicked()'), self.do_user_categories_edit) self.tags_view.set_database(db, self.tag_match, self.popularity, self.search_restriction) self.tags_view.tags_marked.connect(self.search.search_from_tags) for x in (self.saved_search.clear_to_help, self.mark_restriction_set): self.tags_view.restriction_set.connect(x) self.tags_view.tags_marked.connect(self.saved_search.clear_to_help) + self.tags_view.tag_list_edit.connect(self.do_tags_list_edit) + self.tags_view.user_category_edit.connect(self.do_user_categories_edit) + self.tags_view.saved_search_edit.connect(self.do_saved_search_edit) self.search.search.connect(self.tags_view.model().reinit) for x in (self.location_view.count_changed, self.tags_view.recount, self.restriction_count_changed): self.library_view.model().count_changed_signal.connect(x) self.connect(self.search, SIGNAL('cleared()'), self.search_box_cleared) - self.connect(self.saved_search, SIGNAL('changed()'), self.tags_view.saved_searches_changed, Qt.QueuedConnection) + self.connect(self.saved_search, SIGNAL('changed()'), + self.tags_view.saved_searches_changed, Qt.QueuedConnection) if not gprefs.get('quick_start_guide_added', False): from calibre.ebooks.metadata import MetaInformation mi = MetaInformation(_('Calibre Quick Start Guide'), ['John Schember']) @@ -642,13 +648,28 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI): self._add_filesystem_book = Dispatcher(self.__add_filesystem_book) self.keyboard_interrupt.connect(self.quit, type=Qt.QueuedConnection) - def do_edit_categories(self): - d = TagCategories(self, self.library_view.model().db) + def do_user_categories_edit(self, on_category=None): + d = TagCategories(self, self.library_view.model().db, on_category) d.exec_() if d.result() == d.Accepted: self.tags_view.set_new_model() self.tags_view.recount() + def do_tags_list_edit(self): + d = TagListEditor(self, self.library_view.model().db) + d.exec_() + if d.result() == d.Accepted: + self.tags_view.set_new_model() + self.tags_view.recount() + self.library_view.model().refresh() + + def do_saved_search_edit(self, search): + d = SavedSearchEditor(self, search) + d.exec_() + if d.result() == d.Accepted: + self.tags_view.saved_searches_changed(recount=True) + self.saved_search.clear_to_help() + def resizeEvent(self, ev): MainWindow.resizeEvent(self, ev) self.search.setMaximumWidth(self.width()-150)