'+_('Create a virtual library based on: ')+ + ('{0}, ' + '{1}, ' + '{2}, ' + '{3}.').format(_('Authors'), _('Tags'), _('Publishers'), _('Series'))) + sl.setWordWrap(True) + sl.setTextInteractionFlags(Qt.LinksAccessibleByMouse) + sl.linkActivated.connect(self.link_activated) + gl.addWidget(sl, 2, 0, 1, 2) + + self.hl = hl = QLabel(_(''' +
Using virtual libraries you can restrict calibre to only show + you books that match a search. When a virtual library is in effect, calibre + behaves as though the library contains only the matched books. The Tag Browser + display only the tags/authors/series/etc. that belong to the matched books and any searches + you do will only search within the books in the virtual library. This + is a good way to partition your large library into smaller and easier to work with subsets.
+ +For example you can use a Virtual Library to only show you books with the Tag "Unread" + or only books by "My Favorite Author" or only books in a particular series.
+ ''')) + hl.setWordWrap(True) + hl.setFrameStyle(hl.StyledPanel) + gl.addWidget(hl, 0, 3, 4, 1) + + bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + bb.accepted.connect(self.accept) + bb.rejected.connect(self.reject) + gl.addWidget(bb, 4, 0, 1, 0) + + if editing: + db = self.gui.current_db + virt_libs = db.prefs.get('virtual_libraries', {}) + self.vl_text.setText(virt_libs.get(editing, '')) + + self.resize(self.sizeHint()+QSize(150, 25)) + + def link_activated(self, url): + db = self.gui.current_db + f, txt = unicode(url).partition('.')[0::2] + names = getattr(db, 'all_%s_names'%f)() + d = SelectNames(names, txt, parent=self) + if d.exec_() == d.Accepted: + prefix = f+'s' if f in {'tag', 'author'} else f + search = ['%s:"=%s"'%(prefix, x.replace('"', '\\"')) for x in d.names] + if search: + self.vl_name.setText(d.names.next()) + self.vl_text.setText(d.match_type.join(search)) + self.vl_text.setCursorPosition(0) + self.vl_name.setCursorPosition(0) + + def accept(self): + n = unicode(self.vl_name.text()).strip() + if not n: + error_dialog(self.gui, _('No name'), + _('You must provide a name for the new virtual library'), + show=True) + return + + if n.startswith('*'): + error_dialog(self.gui, _('Invalid name'), + _('A virtual library name cannot begin with "*"'), + show=True) + return + + if n in self.existing_names and n != self.editing: + if question_dialog(self.gui, _('Name already in use'), + _('That name is already in use. Do you want to replace it ' + 'with the new search?'), + default_yes=False) == self.Rejected: + return + + v = unicode(self.vl_text.text()).strip() + if not v: + error_dialog(self.gui, _('No search string'), + _('You must provide a search to define the new virtual library'), + show=True) + return + + try: + db = self.gui.library_view.model().db + recs = db.data.search_getting_ids('', v, use_virtual_library=False) + except ParseException as e: + error_dialog(self.gui, _('Invalid search'), + _('The search in the search box is not valid'), + det_msg=e.msg, show=True) + return + + if not recs and not question_dialog( + self.gui, _('Search found no books'), + _('The search found no books, so the virtual library ' + 'will be empty. Do you really want to use that search?'), + default_yes=False): + return + + self.library_name = n + self.library_search = v + QDialog.accept(self) +# }}} class SearchRestrictionMixin(object): + no_restriction = _('' + - self.search_restriction_tooltip + - _(' or the search ') + "'" + search + "'
") - self._apply_search_restriction(search) + self._apply_search_restriction(search, self._trim_restriction_name(s)) def apply_search_restriction(self, i): if i == 1: @@ -66,18 +461,20 @@ class SearchRestrictionMixin(object): restriction = 'search:"%s"'%(r) else: restriction = '' - self._apply_search_restriction(restriction) + self._apply_search_restriction(restriction, r) - def _apply_search_restriction(self, restriction): + def _apply_search_restriction(self, restriction, name): self.saved_search.clear() # The order below is important. Set the restriction, force a '' search # to apply it, reset the tag browser to take it into account, then set # the book count. self.library_view.model().db.data.set_search_restriction(restriction) + self.library_view.model().db.data.set_search_restriction_name(name) self.search.clear(emit_search=True) - self.tags_view.set_search_restriction(restriction) + self.tags_view.recount() self.set_number_of_books_shown() self.current_view().setFocus(Qt.OtherFocusReason) + self.set_window_title() def set_number_of_books_shown(self): db = self.library_view.model().db @@ -86,9 +483,9 @@ class SearchRestrictionMixin(object): rows = self.current_view().row_count() rbc = max(rows, db.data.get_search_restriction_book_count()) t = _("({0} of {1})").format(rows, rbc) - self.search_count.setStyleSheet \ - ('QLabel { border-radius: 8px; background-color: yellow; }') - else: # No restriction or not library view + self.search_count.setStyleSheet( + 'QLabel { border-radius: 8px; background-color: yellow; }') + else: # No restriction or not library view if not self.search.in_a_search(): t = _("(all books)") else: @@ -96,3 +493,14 @@ class SearchRestrictionMixin(object): self.search_count.setStyleSheet( 'QLabel { background-color: transparent; }') self.search_count.setText(t) + +if __name__ == '__main__': + from calibre.gui2 import Application + from calibre.gui2.preferences import init_gui + app = Application([]) + app + gui = init_gui() + d = CreateVirtualLibrary(gui, []) + d.exec_() + + diff --git a/src/calibre/gui2/tag_browser/model.py b/src/calibre/gui2/tag_browser/model.py index 742f2b2776..33d1235f8b 100644 --- a/src/calibre/gui2/tag_browser/model.py +++ b/src/calibre/gui2/tag_browser/model.py @@ -264,13 +264,8 @@ class TagsModel(QAbstractItemModel): # {{{ if rebuild: self.rebuild_node_tree(state_map) - def set_search_restriction(self, s): - self.search_restriction = s - self.rebuild_node_tree() - def set_database(self, db): self.beginResetModel() - self.search_restriction = None hidden_cats = db.prefs.get('tag_browser_hidden_categories', None) # migrate from config to db prefs if hidden_cats is None: @@ -848,7 +843,7 @@ class TagsModel(QAbstractItemModel): # {{{ self.categories = {} # Get the categories - if self.search_restriction: + if self.db.data.get_base_restriction() or self.db.data.get_search_restriction(): try: data = self.db.get_categories(sort=sort, icon_map=self.category_icon_map, diff --git a/src/calibre/gui2/tag_browser/view.py b/src/calibre/gui2/tag_browser/view.py index 7070eaaa04..cefa0f8975 100644 --- a/src/calibre/gui2/tag_browser/view.py +++ b/src/calibre/gui2/tag_browser/view.py @@ -232,10 +232,6 @@ class TagsView(QTreeView): # {{{ except: pass - def set_search_restriction(self, s): - s = s if s else None - self._model.set_search_restriction(s) - def mouseMoveEvent(self, event): dex = self.indexAt(event.pos()) if self.in_drag_drop or not dex.isValid(): diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index 65993ff31c..685604e2ce 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -15,7 +15,7 @@ from threading import Thread from collections import OrderedDict from PyQt4.Qt import (Qt, SIGNAL, QTimer, QHelpEvent, QAction, - QMenu, QIcon, pyqtSignal, QUrl, + QMenu, QIcon, pyqtSignal, QUrl, QFont, QDialog, QSystemTrayIcon, QApplication) from calibre import prints, force_unicode @@ -47,7 +47,7 @@ from calibre.gui2.proceed import ProceedQuestion from calibre.gui2.dialogs.message_box import JobError from calibre.gui2.job_indicator import Pointer -class Listener(Thread): # {{{ +class Listener(Thread): # {{{ def __init__(self, listener): Thread.__init__(self) @@ -76,7 +76,7 @@ class Listener(Thread): # {{{ # }}} -class SystemTrayIcon(QSystemTrayIcon): # {{{ +class SystemTrayIcon(QSystemTrayIcon): # {{{ tooltip_requested = pyqtSignal(object) @@ -98,7 +98,7 @@ _gui = None def get_gui(): return _gui -class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ +class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ TagBrowserMixin, CoverFlowMixin, LibraryViewMixin, SearchBoxMixin, SavedSearchBoxMixin, SearchRestrictionMixin, LayoutMixin, UpdateMixin, EbookDownloadMixin @@ -187,7 +187,6 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ else: stmap[st.name] = st - def initialize(self, library_path, db, listener, actions, show_gui=True): opts = self.opts self.preferences_action, self.quit_action = actions @@ -279,6 +278,7 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ UpdateMixin.__init__(self, opts) ####################### Search boxes ######################## + SearchRestrictionMixin.__init__(self) SavedSearchBoxMixin.__init__(self) SearchBoxMixin.__init__(self) @@ -313,9 +313,8 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ TagBrowserMixin.__init__(self, db) ######################### Search Restriction ########################## - SearchRestrictionMixin.__init__(self) - if db.prefs['gui_restriction']: - self.apply_named_search_restriction(db.prefs['gui_restriction']) + if db.prefs['virtual_lib_on_startup']: + self.apply_virtual_library(db.prefs['virtual_lib_on_startup']) ########################### Cover Flow ################################ @@ -339,7 +338,6 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ if config['autolaunch_server']: self.start_content_server() - self.keyboard_interrupt.connect(self.quit, type=Qt.QueuedConnection) self.read_settings() @@ -393,7 +391,7 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ if not self.device_manager.is_running('Wireless Devices'): error_dialog(self, _('Problem starting the wireless device'), _('The wireless device driver did not start. ' - 'It said "%s"')%message, show=True) + 'It said "%s"')%message, show=True) self.iactions['Connect Share'].set_smartdevice_action_state() def start_content_server(self, check_started=True): @@ -494,7 +492,7 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ path = os.path.abspath(argv[1]) if os.access(path, os.R_OK): self.iactions['Add Books'].add_filesystem_book(path) - self.setWindowState(self.windowState() & \ + self.setWindowState(self.windowState() & ~Qt.WindowMinimized|Qt.WindowActive) self.show_windows() self.raise_() @@ -526,7 +524,8 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ def library_moved(self, newloc, copy_structure=False, call_close=True, allow_rebuild=False): - if newloc is None: return + if newloc is None: + return default_prefs = None try: olddb = self.library_view.model().db @@ -537,7 +536,8 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ try: db = LibraryDatabase2(newloc, default_prefs=default_prefs) except (DatabaseException, sqlite.Error): - if not allow_rebuild: raise + if not allow_rebuild: + raise import traceback repair = question_dialog(self, _('Corrupted database'), _('The library database at %s appears to be corrupted. Do ' @@ -571,8 +571,8 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ db = self.library_view.model().db self.iactions['Choose Library'].count_changed(db.count()) self.set_window_title() - self.apply_named_search_restriction('') # reset restriction to null - self.saved_searches_changed(recount=False) # reload the search restrictions combo box + self.apply_named_search_restriction('') # reset restriction to null + self.saved_searches_changed(recount=False) # reload the search restrictions combo box self.apply_named_search_restriction(db.prefs['gui_restriction']) for action in self.iactions.values(): action.library_changed(db) @@ -596,9 +596,19 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ # interface later gc.collect() - def set_window_title(self): - self.setWindowTitle(__appname__ + u' - || %s ||'%self.iactions['Choose Library'].library_name()) + db = self.current_db + restrictions = [x for x in (db.data.get_base_restriction_name(), + db.data.get_search_restriction_name()) if x] + restrictions = ' :: '.join(restrictions) + font = QFont() + if restrictions: + restrictions = ' :: ' + restrictions + font.setBold(True) + self.virtual_library.setFont(font) + title = u'{0} - || {1}{2} ||'.format( + __appname__, self.iactions['Choose Library'].library_name(), restrictions) + self.setWindowTitle(title) def location_selected(self, location): ''' @@ -613,17 +623,15 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ for action in self.iactions.values(): action.location_selected(location) if location == 'library': - self.search_restriction.setEnabled(True) + self.virtual_library_menu.setEnabled(True) self.highlight_only_button.setEnabled(True) else: - self.search_restriction.setEnabled(False) + self.virtual_library_menu.setEnabled(False) self.highlight_only_button.setEnabled(False) # Reset the view in case something changed while it was invisible self.current_view().reset() self.set_number_of_books_shown() - - def job_exception(self, job, dialog_title=_('Conversion Error')): if not hasattr(self, '_modeless_dialogs'): self._modeless_dialogs = [] @@ -715,7 +723,7 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ self.read_layout_settings() def write_settings(self): - with gprefs: # Only write to gprefs once + with gprefs: # Only write to gprefs once config.set('main_window_geometry', self.saveGeometry()) dynamic.set('sort_history', self.library_view.model().sort_history) self.save_layout_state() @@ -748,7 +756,6 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ return False return True - def shutdown(self, write_settings=True): try: db = self.library_view.model().db @@ -808,13 +815,11 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ pass QApplication.instance().quit() - - def closeEvent(self, e): self.write_settings() if self.system_tray_icon.isVisible(): if not dynamic['systray_msg'] and not isosx: - info_dialog(self, 'calibre', 'calibre '+ \ + info_dialog(self, 'calibre', 'calibre '+ _('will keep running in the system tray. To close it, ' 'choose Quit in the context menu of the ' 'system tray.'), show_copy_button=False).exec_() diff --git a/src/calibre/library/caches.py b/src/calibre/library/caches.py index b453c654df..048288ef71 100644 --- a/src/calibre/library/caches.py +++ b/src/calibre/library/caches.py @@ -209,7 +209,8 @@ class ResultCache(SearchQueryParser): # {{{ self._data = [] self._map = self._map_filtered = [] self.first_sort = True - self.search_restriction = '' + self.search_restriction = self.base_restriction = '' + self.base_restriction_name = self.search_restriction_name = '' self.search_restriction_book_count = 0 self.marked_ids_dict = {} self.field_metadata = field_metadata @@ -825,8 +826,19 @@ class ResultCache(SearchQueryParser): # {{{ return ans self._map_filtered = ans + def _build_restriction_string(self, restriction): + if self.base_restriction: + if restriction: + return u'(%s) and (%s)' % (self.base_restriction, restriction) + else: + return self.base_restriction + else: + return restriction + def search_getting_ids(self, query, search_restriction, - set_restriction_count=False): + set_restriction_count=False, use_virtual_library=True): + if use_virtual_library: + search_restriction = self._build_restriction_string(search_restriction) q = '' if not query or not query.strip(): q = search_restriction @@ -847,11 +859,32 @@ class ResultCache(SearchQueryParser): # {{{ self.search_restriction_book_count = len(rv) return rv + def get_search_restriction(self): + return self.search_restriction + def set_search_restriction(self, s): self.search_restriction = s + def get_base_restriction(self): + return self.base_restriction + + def set_base_restriction(self, s): + self.base_restriction = s + + def get_base_restriction_name(self): + return self.base_restriction_name + + def set_base_restriction_name(self, s): + self.base_restriction_name = s + + def get_search_restriction_name(self): + return self.search_restriction_name + + def set_search_restriction_name(self, s): + self.search_restriction_name = s + def search_restriction_applied(self): - return bool(self.search_restriction) + return bool(self.search_restriction) or bool((self.base_restriction)) def get_search_restriction_book_count(self): return self.search_restriction_book_count @@ -1002,7 +1035,7 @@ class ResultCache(SearchQueryParser): # {{{ if field is not None: self.sort(field, ascending) self._map_filtered = list(self._map) - if self.search_restriction: + if self.search_restriction or self.base_restriction: self.search('', return_matches=False) # Sorting functions {{{ diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py index 8e57647452..0a781e5948 100644 --- a/src/calibre/library/database2.py +++ b/src/calibre/library/database2.py @@ -229,6 +229,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): ('uuid', False), ('comments', True), ('id', False), ('pubdate', False), ('last_modified', False), ('size', False), ('languages', False), ] + defs['virtual_libraries'] = {} + defs['virtual_lib_on_startup'] = defs['cs_virtual_lib_on_startup'] = '' # Migrate the bool tristate tweak defs['bools_are_tristate'] = \ @@ -279,6 +281,24 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns): except: pass + # migrate the gui_restriction preference to a virtual library + gr_pref = self.prefs.get('gui_restriction', None) + if gr_pref: + virt_libs = self.prefs.get('virtual_libraries', {}) + virt_libs[gr_pref] = 'search:"' + gr_pref + '"' + self.prefs['virtual_libraries'] = virt_libs + self.prefs['gui_restriction'] = '' + self.prefs['virtual_lib_on_startup'] = gr_pref + + # migrate the cs_restriction preference to a virtual library + gr_pref = self.prefs.get('cs_restriction', None) + if gr_pref: + virt_libs = self.prefs.get('virtual_libraries', {}) + virt_libs[gr_pref] = 'search:"' + gr_pref + '"' + self.prefs['virtual_libraries'] = virt_libs + self.prefs['cs_restriction'] = '' + self.prefs['cs_virtual_lib_on_startup'] = gr_pref + # Rename any user categories with names that differ only in case user_cats = self.prefs.get('user_categories', []) catmap = {} diff --git a/src/calibre/library/server/base.py b/src/calibre/library/server/base.py index 9c14f128dd..bbd5239b42 100644 --- a/src/calibre/library/server/base.py +++ b/src/calibre/library/server/base.py @@ -205,26 +205,32 @@ class LibraryServer(ContentServer, MobileServer, XMLServer, OPDSServer, Cache, def set_database(self, db): self.db = db + virt_libs = db.prefs.get('virtual_libraries', {}) sr = getattr(self.opts, 'restriction', None) - sr = db.prefs.get('cs_restriction', '') if sr is None else sr - self.set_search_restriction(sr) + if sr: + if sr in virt_libs: + sr = virt_libs[sr] + elif sr not in saved_searches().names(): + prints('WARNING: Content server: search restriction ', + sr, ' does not exist') + sr = '' + else: + sr = 'search:"%s"'%sr + else: + sr = db.prefs.get('cs_virtual_lib_on_startup', '') + if sr: + if sr not in virt_libs: + prints('WARNING: Content server: virtual library ', + sr, ' does not exist') + sr = '' + else: + sr = virt_libs[sr] + self.search_restriction = sr + self.reset_caches() def graceful(self): cherrypy.engine.graceful() - def set_search_restriction(self, restriction): - self.search_restriction_name = restriction - if restriction: - if restriction not in saved_searches().names(): - prints('WARNING: Content server: search restriction ', - restriction, ' does not exist') - self.search_restriction = '' - else: - self.search_restriction = 'search:"%s"'%restriction - else: - self.search_restriction = '' - self.reset_caches() - def setup_loggers(self): access_file = log_access_file error_file = log_error_file diff --git a/src/calibre/library/server/browse.py b/src/calibre/library/server/browse.py index c520e42f34..d25c34d52b 100644 --- a/src/calibre/library/server/browse.py +++ b/src/calibre/library/server/browse.py @@ -145,10 +145,7 @@ def render_rating(rating, url_prefix, container='span', prefix=None): # {{{ # }}} -def get_category_items(category, items, restriction, datatype, prefix): # {{{ - - if category == 'search': - items = [x for x in items if x.name != restriction] +def get_category_items(category, items, datatype, prefix): # {{{ def item(i): templ = (u'