diff --git a/src/calibre/gui2/actions/store.py b/src/calibre/gui2/actions/store.py index b87d1818ea..90f2cac3cd 100644 --- a/src/calibre/gui2/actions/store.py +++ b/src/calibre/gui2/actions/store.py @@ -33,7 +33,7 @@ class StoreAction(InterfaceAction): def search(self): self.show_disclaimer() - from calibre.gui2.store.search import SearchDialog + from calibre.gui2.store.search.search import SearchDialog sd = SearchDialog(self.gui.istores, self.gui) sd.exec_() diff --git a/src/calibre/gui2/store/__init__.py b/src/calibre/gui2/store/__init__.py index 43909e9d8b..16e9f5689d 100644 --- a/src/calibre/gui2/store/__init__.py +++ b/src/calibre/gui2/store/__init__.py @@ -46,9 +46,12 @@ class StorePlugin(object): # {{{ ''' def __init__(self, gui, name): + from calibre.gui2 import JSONConfig + self.gui = gui self.name = name self.base_plugin = None + self.config = JSONConfig('store/stores/' + self.name) def open(self, gui, parent=None, detail_item=None, external=False): ''' diff --git a/src/calibre/gui2/store/basic_config.py b/src/calibre/gui2/store/basic_config.py index 88ee197146..5e59b63694 100644 --- a/src/calibre/gui2/store/basic_config.py +++ b/src/calibre/gui2/store/basic_config.py @@ -8,14 +8,8 @@ __docformat__ = 'restructuredtext en' from PyQt4.Qt import QWidget -from calibre.gui2 import gprefs from calibre.gui2.store.basic_config_widget_ui import Ui_Form -def save_settings(config_widget): - gprefs[config_widget.store.name + '_open_external'] = config_widget.open_external.isChecked() - tags = unicode(config_widget.tags.text()) - gprefs[config_widget.store.name + '_tags'] = tags - class BasicStoreConfigWidget(QWidget, Ui_Form): def __init__(self, store): @@ -27,10 +21,10 @@ class BasicStoreConfigWidget(QWidget, Ui_Form): self.load_setings() def load_setings(self): - settings = self.store.get_settings() + config = self.store.config - self.open_external.setChecked(settings.get(self.store.name + '_open_external')) - self.tags.setText(settings.get(self.store.name + '_tags', '')) + self.open_external.setChecked(config.get('open_external', False)) + self.tags.setText(config.get('tags', '')) class BasicStoreConfig(object): @@ -41,12 +35,6 @@ class BasicStoreConfig(object): return BasicStoreConfigWidget(self) def save_settings(self, config_widget): - save_settings(config_widget) - - def get_settings(self): - settings = {} - - settings[self.name + '_open_external'] = gprefs.get(self.name + '_open_external', False) - settings[self.name + '_tags'] = gprefs.get(self.name + '_tags', self.name + ', store, download') - - return settings + self.config['open_external'] = config_widget.open_external.isChecked() + tags = unicode(config_widget.tags.text()) + self.config['tags'] = tags diff --git a/src/calibre/gui2/store/bewrite_plugin.py b/src/calibre/gui2/store/bewrite_plugin.py index 8cc4a2745f..80f3b2d54e 100644 --- a/src/calibre/gui2/store/bewrite_plugin.py +++ b/src/calibre/gui2/store/bewrite_plugin.py @@ -23,10 +23,9 @@ from calibre.gui2.store.web_store_dialog import WebStoreDialog class BeWriteStore(BasicStoreConfig, StorePlugin): def open(self, parent=None, detail_item=None, external=False): - settings = self.get_settings() url = 'http://www.bewrite.net/mm5/merchant.mvc?Screen=SFNT' - if external or settings.get(self.name + '_open_external', False): + if external or self.config.get('open_external', False): if detail_item: url = url + detail_item open_url(QUrl(url_slash_cleaner(url))) @@ -36,7 +35,7 @@ class BeWriteStore(BasicStoreConfig, StorePlugin): detail_url = url + detail_item d = WebStoreDialog(self.gui, url, parent, detail_url) d.setWindowTitle(self.name) - d.set_tags(settings.get(self.name + '_tags', '')) + d.set_tags(self.config.get('tags', '')) d.exec_() def search(self, query, max_results=10, timeout=60): diff --git a/src/calibre/gui2/store/bn_plugin.py b/src/calibre/gui2/store/bn_plugin.py index 8b1cfa03f0..f26a60c89d 100644 --- a/src/calibre/gui2/store/bn_plugin.py +++ b/src/calibre/gui2/store/bn_plugin.py @@ -25,8 +25,6 @@ from calibre.gui2.store.web_store_dialog import WebStoreDialog class BNStore(BasicStoreConfig, StorePlugin): def open(self, parent=None, detail_item=None, external=False): - settings = self.get_settings() - pub_id = '21000000000352219' # Use Kovid's affiliate id 30% of the time. if random.randint(1, 10) in (1, 2, 3): @@ -40,12 +38,12 @@ class BNStore(BasicStoreConfig, StorePlugin): isbn = mo.group('isbn') detail_item = 'http://gan.doubleclick.net/gan_click?lid=41000000012871747&pid=' + isbn + '&adurl=' + detail_item + '&pubid=' + pub_id - if external or settings.get(self.name + '_open_external', False): + if external or self.config.get('open_external', False): open_url(QUrl(url_slash_cleaner(detail_item if detail_item else url))) else: d = WebStoreDialog(self.gui, url, parent, detail_item) d.setWindowTitle(self.name) - d.set_tags(settings.get(self.name + '_tags', '')) + d.set_tags(self.config.get('tags', '')) d.exec_() def search(self, query, max_results=10, timeout=60): diff --git a/src/calibre/gui2/store/diesel_ebooks_plugin.py b/src/calibre/gui2/store/diesel_ebooks_plugin.py index b33bad15f9..a21d6943d7 100644 --- a/src/calibre/gui2/store/diesel_ebooks_plugin.py +++ b/src/calibre/gui2/store/diesel_ebooks_plugin.py @@ -24,7 +24,6 @@ from calibre.gui2.store.web_store_dialog import WebStoreDialog class DieselEbooksStore(BasicStoreConfig, StorePlugin): def open(self, parent=None, detail_item=None, external=False): - settings = self.get_settings() url = 'http://www.diesel-ebooks.com/' aff_id = '?aid=2049' @@ -37,12 +36,12 @@ class DieselEbooksStore(BasicStoreConfig, StorePlugin): detail_url = url + detail_item + aff_id url = url + aff_id - if external or settings.get(self.name + '_open_external', False): + if external or self.config.get('open_external', False): open_url(QUrl(url_slash_cleaner(detail_url if detail_url else url))) else: d = WebStoreDialog(self.gui, url, parent, detail_url) d.setWindowTitle(self.name) - d.set_tags(settings.get(self.name + '_tags', '')) + d.set_tags(self.config.get('tags', '')) d.exec_() def search(self, query, max_results=10, timeout=60): diff --git a/src/calibre/gui2/store/ebooks_com_plugin.py b/src/calibre/gui2/store/ebooks_com_plugin.py index 1597cc89ca..85176c10d7 100644 --- a/src/calibre/gui2/store/ebooks_com_plugin.py +++ b/src/calibre/gui2/store/ebooks_com_plugin.py @@ -25,8 +25,6 @@ from calibre.gui2.store.web_store_dialog import WebStoreDialog class EbookscomStore(BasicStoreConfig, StorePlugin): def open(self, parent=None, detail_item=None, external=False): - settings = self.get_settings() - m_url = 'http://www.dpbolvw.net/' h_click = 'click-4879827-10364500' d_click = 'click-4879827-10281551' @@ -40,12 +38,12 @@ class EbookscomStore(BasicStoreConfig, StorePlugin): if detail_item: detail_url = m_url + d_click + detail_item - if external or settings.get(self.name + '_open_external', False): + if external or self.config.get('open_external', False): open_url(QUrl(url_slash_cleaner(detail_url if detail_url else url))) else: d = WebStoreDialog(self.gui, url, parent, detail_url) d.setWindowTitle(self.name) - d.set_tags(settings.get(self.name + '_tags', '')) + d.set_tags(self.config.get('tags', '')) d.exec_() def search(self, query, max_results=10, timeout=60): diff --git a/src/calibre/gui2/store/eharlequin_plugin.py b/src/calibre/gui2/store/eharlequin_plugin.py index 4f54508c80..daa67e801c 100644 --- a/src/calibre/gui2/store/eharlequin_plugin.py +++ b/src/calibre/gui2/store/eharlequin_plugin.py @@ -25,8 +25,6 @@ from calibre.gui2.store.web_store_dialog import WebStoreDialog class EHarlequinStore(BasicStoreConfig, StorePlugin): def open(self, parent=None, detail_item=None, external=False): - settings = self.get_settings() - m_url = 'http://www.dpbolvw.net/' h_click = 'click-4879827-534091' d_click = 'click-4879827-10375439' @@ -40,12 +38,12 @@ class EHarlequinStore(BasicStoreConfig, StorePlugin): if detail_item: detail_url = m_url + d_click + detail_item - if external or settings.get(self.name + '_open_external', False): + if external or self.config.get('open_external', False): open_url(QUrl(url_slash_cleaner(detail_url if detail_url else url))) else: d = WebStoreDialog(self.gui, url, parent, detail_url) d.setWindowTitle(self.name) - d.set_tags(settings.get(self.name + '_tags', '')) + d.set_tags(self.config.get('tags', '')) d.exec_() def search(self, query, max_results=10, timeout=60): diff --git a/src/calibre/gui2/store/feedbooks_plugin.py b/src/calibre/gui2/store/feedbooks_plugin.py index e56964b339..9b1f7f6574 100644 --- a/src/calibre/gui2/store/feedbooks_plugin.py +++ b/src/calibre/gui2/store/feedbooks_plugin.py @@ -23,11 +23,10 @@ from calibre.gui2.store.web_store_dialog import WebStoreDialog class FeedbooksStore(BasicStoreConfig, StorePlugin): def open(self, parent=None, detail_item=None, external=False): - settings = self.get_settings() url = 'http://m.feedbooks.com/' ext_url = 'http://feedbooks.com/' - if external or settings.get(self.name + '_open_external', False): + if external or self.config.get('open_external', False): if detail_item: ext_url = ext_url + detail_item open_url(QUrl(url_slash_cleaner(ext_url))) @@ -37,7 +36,7 @@ class FeedbooksStore(BasicStoreConfig, StorePlugin): detail_url = url + detail_item d = WebStoreDialog(self.gui, url, parent, detail_url) d.setWindowTitle(self.name) - d.set_tags(settings.get(self.name + '_tags', '')) + d.set_tags(self.config.get('tags', '')) d.exec_() def search(self, query, max_results=10, timeout=60): diff --git a/src/calibre/gui2/store/gutenberg_plugin.py b/src/calibre/gui2/store/gutenberg_plugin.py index 04fe4da0fb..d820a44f8d 100644 --- a/src/calibre/gui2/store/gutenberg_plugin.py +++ b/src/calibre/gui2/store/gutenberg_plugin.py @@ -23,11 +23,10 @@ from calibre.gui2.store.web_store_dialog import WebStoreDialog class GutenbergStore(BasicStoreConfig, StorePlugin): def open(self, parent=None, detail_item=None, external=False): - settings = self.get_settings() url = 'http://m.gutenberg.org/' ext_url = 'http://gutenberg.org/' - if external or settings.get(self.name + '_open_external', False): + if external or self.config.get('open_external', False): if detail_item: ext_url = ext_url + detail_item open_url(QUrl(url_slash_cleaner(ext_url))) @@ -37,7 +36,7 @@ class GutenbergStore(BasicStoreConfig, StorePlugin): detail_url = url + detail_item d = WebStoreDialog(self.gui, url, parent, detail_url) d.setWindowTitle(self.name) - d.set_tags(settings.get(self.name + '_tags', '')) + d.set_tags(self.config.get('tags', '')) d.exec_() def search(self, query, max_results=10, timeout=60): diff --git a/src/calibre/gui2/store/kobo_plugin.py b/src/calibre/gui2/store/kobo_plugin.py index 421348d210..9ec0e4b786 100644 --- a/src/calibre/gui2/store/kobo_plugin.py +++ b/src/calibre/gui2/store/kobo_plugin.py @@ -24,8 +24,6 @@ from calibre.gui2.store.web_store_dialog import WebStoreDialog class KoboStore(BasicStoreConfig, StorePlugin): def open(self, parent=None, detail_item=None, external=False): - settings = self.get_settings() - m_url = 'http://www.dpbolvw.net/' h_click = 'click-4879827-10762497' d_click = 'click-4879827-10772898' @@ -39,12 +37,12 @@ class KoboStore(BasicStoreConfig, StorePlugin): if detail_item: detail_url = m_url + d_click + detail_item - if external or settings.get(self.name + '_open_external', False): + if external or self.config.get('open_external', False): open_url(QUrl(url_slash_cleaner(detail_url if detail_url else url))) else: d = WebStoreDialog(self.gui, url, parent, detail_url) d.setWindowTitle(self.name) - d.set_tags(settings.get(self.name + '_tags', '')) + d.set_tags(self.config.get('tags', '')) d.exec_() def search(self, query, max_results=10, timeout=60): diff --git a/src/calibre/gui2/store/manybooks_plugin.py b/src/calibre/gui2/store/manybooks_plugin.py index 57eb42c13e..1ae9d47d01 100644 --- a/src/calibre/gui2/store/manybooks_plugin.py +++ b/src/calibre/gui2/store/manybooks_plugin.py @@ -24,19 +24,18 @@ from calibre.gui2.store.web_store_dialog import WebStoreDialog class ManyBooksStore(BasicStoreConfig, StorePlugin): def open(self, parent=None, detail_item=None, external=False): - settings = self.get_settings() url = 'http://manybooks.net/' detail_url = None if detail_item: detail_url = url + detail_item - if external or settings.get(self.name + '_open_external', False): + if external or self.config.get('open_external', False): open_url(QUrl(url_slash_cleaner(detail_url if detail_url else url))) else: d = WebStoreDialog(self.gui, url, parent, detail_url) d.setWindowTitle(self.name) - d.set_tags(settings.get(self.name + '_tags', '')) + d.set_tags(self.config.get('tags', '')) d.exec_() def search(self, query, max_results=10, timeout=60): diff --git a/src/calibre/gui2/store/mobileread_plugin.py b/src/calibre/gui2/store/mobileread_plugin.py index 72813982dc..25125d38c0 100644 --- a/src/calibre/gui2/store/mobileread_plugin.py +++ b/src/calibre/gui2/store/mobileread_plugin.py @@ -18,7 +18,7 @@ from PyQt4.Qt import Qt, QUrl, QDialog, QAbstractItemModel, QModelIndex, QVarian pyqtSignal from calibre import browser -from calibre.gui2 import open_url, NONE, JSONConfig +from calibre.gui2 import open_url, NONE from calibre.gui2.store import StorePlugin from calibre.gui2.store.basic_config import BasicStoreConfig from calibre.gui2.store.mobileread_store_dialog_ui import Ui_Dialog @@ -29,20 +29,18 @@ from calibre.utils.icu import sort_key class MobileReadStore(BasicStoreConfig, StorePlugin): def genesis(self): - self.config = JSONConfig('store/store/' + self.name) self.rlock = RLock() def open(self, parent=None, detail_item=None, external=False): - settings = self.get_settings() url = 'http://www.mobileread.com/' - if external or settings.get(self.name + '_open_external', False): + if external or self.config.get('open_external', False): open_url(QUrl(detail_item if detail_item else url)) else: if detail_item: d = WebStoreDialog(self.gui, url, parent, detail_item) d.setWindowTitle(self.name) - d.set_tags(settings.get(self.name + '_tags', '')) + d.set_tags(self.config.get('tags', '')) d.exec_() else: d = MobeReadStoreDialog(self, parent) diff --git a/src/calibre/gui2/store/open_library_plugin.py b/src/calibre/gui2/store/open_library_plugin.py index 0e2fa4b14f..93b9c02dcf 100644 --- a/src/calibre/gui2/store/open_library_plugin.py +++ b/src/calibre/gui2/store/open_library_plugin.py @@ -23,10 +23,9 @@ from calibre.gui2.store.web_store_dialog import WebStoreDialog class OpenLibraryStore(BasicStoreConfig, StorePlugin): def open(self, parent=None, detail_item=None, external=False): - settings = self.get_settings() url = 'http://openlibrary.org/' - if external or settings.get(self.name + '_open_external', False): + if external or self.config.get('open_external', False): if detail_item: url = url + detail_item open_url(QUrl(url_slash_cleaner(url))) @@ -36,7 +35,7 @@ class OpenLibraryStore(BasicStoreConfig, StorePlugin): detail_url = url + detail_item d = WebStoreDialog(self.gui, url, parent, detail_url) d.setWindowTitle(self.name) - d.set_tags(settings.get(self.name + '_tags', '')) + d.set_tags(self.config.get('tags', '')) d.exec_() def search(self, query, max_results=10, timeout=60): diff --git a/src/calibre/gui2/store/search.py b/src/calibre/gui2/store/search.py deleted file mode 100644 index 69c3bd2f06..0000000000 --- a/src/calibre/gui2/store/search.py +++ /dev/null @@ -1,726 +0,0 @@ -# -*- coding: utf-8 -*- - -from __future__ import (unicode_literals, division, absolute_import, print_function) - -__license__ = 'GPL 3' -__copyright__ = '2011, John Schember ' -__docformat__ = 'restructuredtext en' - -import re -import time -import traceback -from contextlib import closing -from operator import attrgetter -from random import shuffle -from threading import Thread -from Queue import Queue - -from PyQt4.Qt import (Qt, QAbstractItemModel, QDialog, QTimer, QVariant, - QModelIndex, QPixmap, QSize, QCheckBox, QVBoxLayout) - -from calibre import browser -from calibre.gui2 import NONE, JSONConfig -from calibre.gui2.progress_indicator import ProgressIndicator -from calibre.gui2.store.search_ui import Ui_Dialog -from calibre.gui2.store.search_result import SearchResult -from calibre.library.caches import _match, CONTAINS_MATCH, EQUALS_MATCH, \ - REGEXP_MATCH -from calibre.utils.icu import sort_key -from calibre.utils.magick.draw import thumbnail -from calibre.utils.search_query_parser import SearchQueryParser - -HANG_TIME = 75000 # milliseconds seconds -TIMEOUT = 75 # seconds -SEARCH_THREAD_TOTAL = 4 -COVER_DOWNLOAD_THREAD_TOTAL = 2 - -def comparable_price(text): - if len(text) < 3 or text[-3] not in ('.', ','): - text += '00' - text = re.sub(r'\D', '', text) - text = text.rjust(6, '0') - return text - - -class SearchDialog(QDialog, Ui_Dialog): - - def __init__(self, istores, *args): - QDialog.__init__(self, *args) - self.setupUi(self) - - self.config = JSONConfig('store/search') - - # We keep a cache of store plugins and reference them by name. - self.store_plugins = istores - self.search_pool = SearchThreadPool(SearchThread, SEARCH_THREAD_TOTAL) - # Check for results and hung threads. - self.checker = QTimer() - self.hang_check = 0 - - self.model = Matches() - self.results_view.setModel(self.model) - - # Add check boxes for each store so the user - # can disable searching specific stores on a - # per search basis. - stores_group_layout = QVBoxLayout() - self.stores_group.setLayout(stores_group_layout) - for x in self.store_plugins: - cbox = QCheckBox(x) - cbox.setChecked(True) - stores_group_layout.addWidget(cbox) - setattr(self, 'store_check_' + x, cbox) - stores_group_layout.addStretch() - - # Create and add the progress indicator - self.pi = ProgressIndicator(self, 24) - self.bottom_layout.insertWidget(0, self.pi) - - self.search.clicked.connect(self.do_search) - self.checker.timeout.connect(self.get_results) - self.results_view.activated.connect(self.open_store) - self.select_all_stores.clicked.connect(self.stores_select_all) - self.select_invert_stores.clicked.connect(self.stores_select_invert) - self.select_none_stores.clicked.connect(self.stores_select_none) - self.finished.connect(self.dialog_closed) - - self.restore_state() - - def resize_columns(self): - total = 600 - # Cover - self.results_view.setColumnWidth(0, 85) - total = total - 85 - # Title - self.results_view.setColumnWidth(1,int(total*.35)) - # Author - self.results_view.setColumnWidth(2,int(total*.35)) - # Price - self.results_view.setColumnWidth(3, int(total*.5)) - # DRM - self.results_view.setColumnWidth(4, int(total*.5)) - # Store - self.results_view.setColumnWidth(5, int(total*.15)) - # Formats - self.results_view.setColumnWidth(6, int(total*.5)) - - def do_search(self, checked=False): - # Stop all running threads. - self.checker.stop() - self.search_pool.abort() - # Clear the visible results. - self.results_view.model().clear_results() - - # Don't start a search if there is nothing to search for. - query = unicode(self.search_edit.text()) - if not query.strip(): - return - # Give the query to the results model so it can do - # futher filtering. - self.results_view.model().set_query(query) - - # Plugins are in alphebetic order. Randomize the - # order of plugin names. This way plugins closer - # to a don't have an unfair advantage over - # plugins further from a. - store_names = self.store_plugins.keys() - if not store_names: - return - # Remove all of our internal filtering logic from the query. - query = self.clean_query(query) - shuffle(store_names) - # Add plugins that the user has checked to the search pool's work queue. - for n in store_names: - if getattr(self, 'store_check_' + n).isChecked(): - self.search_pool.add_task(query, n, self.store_plugins[n], TIMEOUT) - if self.search_pool.has_tasks(): - self.hang_check = 0 - self.checker.start(100) - self.search_pool.start_threads() - self.pi.startAnimation() - - def clean_query(self, query): - query = query.lower() - # Remove control modifiers. - query = query.replace('\\', '') - query = query.replace('!', '') - query = query.replace('=', '') - query = query.replace('~', '') - query = query.replace('>', '') - query = query.replace('<', '') - # Remove the prefix. - for loc in ( 'all', 'author', 'authors', 'title'): - query = re.sub(r'%s:"?(?P[^\s"]+)"?' % loc, '\g', query) - # Remove the prefix and search text. - for loc in ('cover', 'drm', 'format', 'formats', 'price', 'store'): - query = re.sub(r'%s:"[^"]"' % loc, '', query) - query = re.sub(r'%s:[^\s]*' % loc, '', query) - # Remove logic. - query = re.sub(r'(^|\s)(and|not|or)(\s|$)', ' ', query) - # Remove excess whitespace. - query = re.sub(r'\s{2,}', ' ', query) - query = query.strip() - return query - - def save_state(self): - self.config['store_search_geometry'] = bytearray(self.saveGeometry()) - self.config['store_search_store_splitter_state'] = bytearray(self.store_splitter.saveState()) - self.config['store_search_results_view_column_width'] = [self.results_view.columnWidth(i) for i in range(self.model.columnCount())] - - store_check = {} - for n in self.store_plugins: - store_check[n] = getattr(self, 'store_check_' + n).isChecked() - self.config['store_search_store_checked'] = store_check - - def restore_state(self): - geometry = self.config.get('store_search_geometry', None) - if geometry: - self.restoreGeometry(geometry) - - splitter_state = self.config.get('store_search_store_splitter_state', None) - if splitter_state: - self.store_splitter.restoreState(splitter_state) - - results_cwidth = self.config.get('store_search_results_view_column_width', None) - if results_cwidth: - for i, x in enumerate(results_cwidth): - if i >= self.model.columnCount(): - break - self.results_view.setColumnWidth(i, x) - else: - self.resize_columns() - - store_check = self.config.get('store_search_store_checked', None) - if store_check: - for n in store_check: - if hasattr(self, 'store_check_' + n): - getattr(self, 'store_check_' + n).setChecked(store_check[n]) - - def get_results(self): - # We only want the search plugins to run - # a maximum set amount of time before giving up. - self.hang_check += 1 - if self.hang_check >= HANG_TIME: - self.search_pool.abort() - self.checker.stop() - self.pi.stopAnimation() - else: - # Stop the checker if not threads are running. - if not self.search_pool.threads_running() and not self.search_pool.has_tasks(): - self.checker.stop() - self.pi.stopAnimation() - - while self.search_pool.has_results(): - res, store_plugin = self.search_pool.get_result() - if res: - self.results_view.model().add_result(res, store_plugin) - - def open_store(self, index): - result = self.results_view.model().get_result(index) - self.store_plugins[result.store_name].open(self, result.detail_item) - - def get_store_checks(self): - ''' - Returns a list of QCheckBox's for each store. - ''' - checks = [] - for x in self.store_plugins: - check = getattr(self, 'store_check_' + x, None) - if check: - checks.append(check) - return checks - - def stores_select_all(self): - for check in self.get_store_checks(): - check.setChecked(True) - - def stores_select_invert(self): - for check in self.get_store_checks(): - check.setChecked(not check.isChecked()) - - def stores_select_none(self): - for check in self.get_store_checks(): - check.setChecked(False) - - def dialog_closed(self, result): - self.model.closing() - self.search_pool.abort() - self.save_state() - - -class GenericDownloadThreadPool(object): - ''' - add_task must be implemented in a subclass. - ''' - - def __init__(self, thread_type, thread_count): - self.thread_type = thread_type - self.thread_count = thread_count - - self.tasks = Queue() - self.results = Queue() - self.threads = [] - - def add_task(self): - raise NotImplementedError() - - def start_threads(self): - for i in range(self.thread_count): - t = self.thread_type(self.tasks, self.results) - self.threads.append(t) - t.start() - - def abort(self): - self.tasks = Queue() - self.results = Queue() - for t in self.threads: - t.abort() - self.threads = [] - - def has_tasks(self): - return not self.tasks.empty() - - def get_result(self): - return self.results.get() - - def get_result_no_wait(self): - return self.results.get_nowait() - - def result_count(self): - return len(self.results) - - def has_results(self): - return not self.results.empty() - - def threads_running(self): - for t in self.threads: - if t.is_alive(): - return True - return False - - -class SearchThreadPool(GenericDownloadThreadPool): - ''' - Threads will run until there is no work or - abort is called. Create and start new threads - using start_threads(). Reset by calling abort(). - - Example: - sp = SearchThreadPool(SearchThread, 3) - add tasks using add_task(...) - sp.start_threads() - all threads have finished. - sp.abort() - add tasks using add_task(...) - sp.start_threads() - ''' - - def add_task(self, query, store_name, store_plugin, timeout): - self.tasks.put((query, store_name, store_plugin, timeout)) - - -class SearchThread(Thread): - - def __init__(self, tasks, results): - Thread.__init__(self) - self.daemon = True - self.tasks = tasks - self.results = results - self._run = True - - def abort(self): - self._run = False - - def run(self): - while self._run and not self.tasks.empty(): - try: - query, store_name, store_plugin, timeout = self.tasks.get() - for res in store_plugin.search(query, timeout=timeout): - if not self._run: - return - res.store_name = store_name - self.results.put((res, store_plugin)) - self.tasks.task_done() - except: - traceback.print_exc() - - -class CoverThreadPool(GenericDownloadThreadPool): - ''' - Once started all threads run until abort is called. - ''' - - def add_task(self, search_result, update_callback, timeout=5): - self.tasks.put((search_result, update_callback, timeout)) - - -class CoverThread(Thread): - - def __init__(self, tasks, results): - Thread.__init__(self) - self.daemon = True - self.tasks = tasks - self.results = results - self._run = True - - self.br = browser() - - def abort(self): - self._run = False - - def run(self): - while self._run: - try: - time.sleep(.1) - while not self.tasks.empty(): - if not self._run: - break - result, callback, timeout = self.tasks.get() - if result and result.cover_url: - with closing(self.br.open(result.cover_url, timeout=timeout)) as f: - result.cover_data = f.read() - result.cover_data = thumbnail(result.cover_data, 64, 64)[2] - callback() - self.tasks.task_done() - except: - continue - - -class DetailsThreadPool(GenericDownloadThreadPool): - ''' - Once started all threads run until abort is called. - ''' - - def add_task(self, search_result, store_plugin, update_callback, timeout=10): - self.tasks.put((search_result, store_plugin, update_callback, timeout)) - - -class DetailsThread(Thread): - - def __init__(self, tasks, results): - Thread.__init__(self) - self.daemon = True - self.tasks = tasks - self.results = results - self._run = True - - def abort(self): - self._run = False - - def run(self): - while self._run: - try: - time.sleep(.1) - while not self.tasks.empty(): - if not self._run: - break - result, store_plugin, callback, timeout = self.tasks.get() - if result: - store_plugin.get_details(result, timeout) - callback(result) - self.tasks.task_done() - except: - continue - -class Matches(QAbstractItemModel): - - HEADERS = [_('Cover'), _('Title'), _('Author(s)'), _('Price'), _('DRM'), _('Store'), _('Formats')] - - def __init__(self): - QAbstractItemModel.__init__(self) - - self.DRM_LOCKED_ICON = QPixmap(I('drm-locked.png')).scaledToHeight(64, - Qt.SmoothTransformation) - self.DRM_UNLOCKED_ICON = QPixmap(I('drm-unlocked.png')).scaledToHeight(64, - Qt.SmoothTransformation) - self.DRM_UNKNOWN_ICON = QPixmap(I('dialog_question.png')).scaledToHeight(64, - Qt.SmoothTransformation) - - # All matches. Used to determine the order to display - # self.matches because the SearchFilter returns - # matches unordered. - self.all_matches = [] - # Only the showing matches. - self.matches = [] - self.query = '' - self.search_filter = SearchFilter() - self.cover_pool = CoverThreadPool(CoverThread, 2) - self.cover_pool.start_threads() - self.details_pool = DetailsThreadPool(DetailsThread, 4) - self.details_pool.start_threads() - - def closing(self): - self.cover_pool.abort() - self.details_pool.abort() - - def clear_results(self): - self.all_matches = [] - self.matches = [] - self.all_matches = [] - self.search_filter.clear_search_results() - self.query = '' - self.cover_pool.abort() - self.cover_pool.start_threads() - self.details_pool.abort() - self.details_pool.start_threads() - self.reset() - - def add_result(self, result, store_plugin): - if result not in self.all_matches: - self.layoutAboutToBeChanged.emit() - self.all_matches.append(result) - self.search_filter.add_search_result(result) - if result.cover_url: - result.cover_queued = True - self.cover_pool.add_task(result, self.filter_results) - else: - result.cover_queued = False - self.details_pool.add_task(result, store_plugin, self.got_result_details) - self.filter_results() - self.layoutChanged.emit() - - def get_result(self, index): - row = index.row() - if row < len(self.matches): - return self.matches[row] - else: - return None - - def filter_results(self): - self.layoutAboutToBeChanged.emit() - if self.query: - self.matches = list(self.search_filter.parse(self.query)) - else: - self.matches = list(self.search_filter.universal_set()) - self.reorder_matches() - self.layoutChanged.emit() - - def got_result_details(self, result): - if not result.cover_queued and result.cover_url: - result.cover_queued = True - self.cover_pool.add_task(result, self.filter_results) - if result in self.matches: - row = self.matches.index(result) - self.dataChanged.emit(self.index(row, 0), self.index(row, self.columnCount() - 1)) - if result.drm not in (SearchResult.DRM_LOCKED, SearchResult.DRM_UNLOCKED, SearchResult.DRM_UNKNOWN): - result.drm = SearchResult.DRM_UNKNOWN - self.filter_results() - - def set_query(self, query): - self.query = query - - def index(self, row, column, parent=QModelIndex()): - return self.createIndex(row, column) - - def parent(self, index): - if not index.isValid() or index.internalId() == 0: - return QModelIndex() - return self.createIndex(0, 0) - - def rowCount(self, *args): - return len(self.matches) - - def columnCount(self, *args): - return len(self.HEADERS) - - def headerData(self, section, orientation, role): - if role != Qt.DisplayRole: - return NONE - text = '' - if orientation == Qt.Horizontal: - if section < len(self.HEADERS): - text = self.HEADERS[section] - return QVariant(text) - else: - return QVariant(section+1) - - def data(self, index, role): - row, col = index.row(), index.column() - result = self.matches[row] - if role == Qt.DisplayRole: - if col == 1: - return QVariant(result.title) - elif col == 2: - return QVariant(result.author) - elif col == 3: - return QVariant(result.price) - elif col == 5: - return QVariant(result.store_name) - elif col == 6: - return QVariant(result.formats) - return NONE - elif role == Qt.DecorationRole: - if col == 0 and result.cover_data: - p = QPixmap() - p.loadFromData(result.cover_data) - return QVariant(p) - if col == 4: - if result.drm == SearchResult.DRM_LOCKED: - return QVariant(self.DRM_LOCKED_ICON) - elif result.drm == SearchResult.DRM_UNLOCKED: - return QVariant(self.DRM_UNLOCKED_ICON) - elif result.drm == SearchResult.DRM_UNKNOWN: - return QVariant(self.DRM_UNKNOWN_ICON) - elif role == Qt.ToolTipRole: - if col == 1: - return QVariant('

%s

' % result.title) - elif col == 2: - return QVariant('

%s

' % result.author) - elif col == 3: - return QVariant('

' + _('Detected price as: %s. Check with the store before making a purchase to verify this price is correct. This price often does not include promotions the store may be running.') % result.price + '

') - elif col == 4: - if result.drm == SearchResult.DRM_LOCKED: - return QVariant('

' + _('This book as been detected as having DRM restrictions. This book may not work with your reader and you will have limitations placed upon you as to what you can do with this book. Check with the store before making any purchases to ensure you can actually read this book.') + '

') - elif result.drm == SearchResult.DRM_UNLOCKED: - return QVariant('

' + _('This book has been detected as being DRM Free. You should be able to use this book on any device provided it is in a format calibre supports for conversion. However, before making a purchase double check the DRM status with the store. The store may not be disclosing the use of DRM.') + '

') - else: - return QVariant('

' + _('The DRM status of this book could not be determined. There is a very high likelihood that this book is actually DRM restricted.') + '

') - elif col == 5: - return QVariant('

%s

' % result.store_name) - elif col == 6: - return QVariant('

%s

' % result.formats) - elif role == Qt.SizeHintRole: - return QSize(64, 64) - return NONE - - def data_as_text(self, result, col): - text = '' - if col == 1: - text = result.title - elif col == 2: - text = result.author - elif col == 3: - text = comparable_price(result.price) - elif col == 4: - if result.drm == SearchResult.DRM_UNLOCKED: - text = 'a' - elif result.drm == SearchResult.DRM_LOCKED: - text = 'b' - else: - text = 'c' - elif col == 5: - text = result.store_name - elif col == 6: - text = ', '.join(sorted(result.formats.split(','))) - return text - - def sort(self, col, order, reset=True): - if not self.matches: - return - descending = order == Qt.DescendingOrder - self.all_matches.sort(None, - lambda x: sort_key(unicode(self.data_as_text(x, col))), - descending) - self.reorder_matches() - if reset: - self.reset() - - def reorder_matches(self): - self.matches = sorted(self.matches, key=lambda x: self.all_matches.index(x)) - - -class SearchFilter(SearchQueryParser): - - USABLE_LOCATIONS = [ - 'all', - 'author', - 'authors', - 'cover', - 'drm', - 'format', - 'formats', - 'price', - 'title', - 'store', - ] - - def __init__(self): - SearchQueryParser.__init__(self, locations=self.USABLE_LOCATIONS) - self.srs = set([]) - - def add_search_result(self, search_result): - self.srs.add(search_result) - - def clear_search_results(self): - self.srs = set([]) - - def universal_set(self): - return self.srs - - def get_matches(self, location, query): - location = location.lower().strip() - if location == 'authors': - location = 'author' - elif location == 'formats': - location = 'format' - - matchkind = CONTAINS_MATCH - if len(query) > 1: - if query.startswith('\\'): - query = query[1:] - elif query.startswith('='): - matchkind = EQUALS_MATCH - query = query[1:] - elif query.startswith('~'): - matchkind = REGEXP_MATCH - query = query[1:] - if matchkind != REGEXP_MATCH: ### leave case in regexps because it can be significant e.g. \S \W \D - query = query.lower() - - if location not in self.USABLE_LOCATIONS: - return set([]) - matches = set([]) - all_locs = set(self.USABLE_LOCATIONS) - set(['all']) - locations = all_locs if location == 'all' else [location] - q = { - 'author': lambda x: x.author.lower(), - 'cover': attrgetter('cover_url'), - 'drm': attrgetter('drm'), - 'format': attrgetter('formats'), - 'price': lambda x: comparable_price(x.price), - 'store': lambda x: x.store_name.lower(), - 'title': lambda x: x.title.lower(), - } - for x in ('author', 'format'): - q[x+'s'] = q[x] - for sr in self.srs: - for locvalue in locations: - accessor = q[locvalue] - if query == 'true': - if locvalue == 'drm': - if accessor(sr) == SearchResult.DRM_LOCKED: - matches.add(sr) - else: - if accessor(sr) is not None: - matches.add(sr) - continue - if query == 'false': - if locvalue == 'drm': - if accessor(sr) == SearchResult.DRM_UNLOCKED: - matches.add(sr) - else: - if accessor(sr) is None: - matches.add(sr) - continue - # this is bool, so can't match below - if locvalue == 'drm': - continue - try: - ### Can't separate authors because comma is used for name sep and author sep - ### Exact match might not get what you want. For that reason, turn author - ### exactmatch searches into contains searches. - if locvalue == 'author' and matchkind == EQUALS_MATCH: - m = CONTAINS_MATCH - else: - m = matchkind - - if locvalue == 'format': - vals = accessor(sr).split(',') - else: - vals = [accessor(sr)] - if _match(query, vals, m): - matches.add(sr) - break - except ValueError: # Unicode errors - traceback.print_exc() - return matches diff --git a/src/calibre/gui2/store/search/__init__.py b/src/calibre/gui2/store/search/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/calibre/gui2/store/search/download_thread.py b/src/calibre/gui2/store/search/download_thread.py new file mode 100644 index 0000000000..a6f92011f6 --- /dev/null +++ b/src/calibre/gui2/store/search/download_thread.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- + +from __future__ import (unicode_literals, division, absolute_import, print_function) + +__license__ = 'GPL 3' +__copyright__ = '2011, John Schember ' +__docformat__ = 'restructuredtext en' + +import time +import traceback +from contextlib import closing +from threading import Thread +from Queue import Queue + +from calibre import browser +from calibre.utils.magick.draw import thumbnail + +class GenericDownloadThreadPool(object): + ''' + add_task must be implemented in a subclass. + ''' + + def __init__(self, thread_type, thread_count): + self.thread_type = thread_type + self.thread_count = thread_count + + self.tasks = Queue() + self.results = Queue() + self.threads = [] + + def add_task(self): + raise NotImplementedError() + + def start_threads(self): + for i in range(self.thread_count): + t = self.thread_type(self.tasks, self.results) + self.threads.append(t) + t.start() + + def abort(self): + self.tasks = Queue() + self.results = Queue() + for t in self.threads: + t.abort() + self.threads = [] + + def has_tasks(self): + return not self.tasks.empty() + + def get_result(self): + return self.results.get() + + def get_result_no_wait(self): + return self.results.get_nowait() + + def result_count(self): + return len(self.results) + + def has_results(self): + return not self.results.empty() + + def threads_running(self): + for t in self.threads: + if t.is_alive(): + return True + return False + + +class SearchThreadPool(GenericDownloadThreadPool): + ''' + Threads will run until there is no work or + abort is called. Create and start new threads + using start_threads(). Reset by calling abort(). + + Example: + sp = SearchThreadPool(SearchThread, 3) + add tasks using add_task(...) + sp.start_threads() + all threads have finished. + sp.abort() + add tasks using add_task(...) + sp.start_threads() + ''' + + def add_task(self, query, store_name, store_plugin, timeout): + self.tasks.put((query, store_name, store_plugin, timeout)) + + +class SearchThread(Thread): + + def __init__(self, tasks, results): + Thread.__init__(self) + self.daemon = True + self.tasks = tasks + self.results = results + self._run = True + + def abort(self): + self._run = False + + def run(self): + while self._run and not self.tasks.empty(): + try: + query, store_name, store_plugin, timeout = self.tasks.get() + for res in store_plugin.search(query, timeout=timeout): + if not self._run: + return + res.store_name = store_name + self.results.put((res, store_plugin)) + self.tasks.task_done() + except: + traceback.print_exc() + + +class CoverThreadPool(GenericDownloadThreadPool): + ''' + Once started all threads run until abort is called. + ''' + + def add_task(self, search_result, update_callback, timeout=5): + self.tasks.put((search_result, update_callback, timeout)) + + +class CoverThread(Thread): + + def __init__(self, tasks, results): + Thread.__init__(self) + self.daemon = True + self.tasks = tasks + self.results = results + self._run = True + + self.br = browser() + + def abort(self): + self._run = False + + def run(self): + while self._run: + try: + time.sleep(.1) + while not self.tasks.empty(): + if not self._run: + break + result, callback, timeout = self.tasks.get() + if result and result.cover_url: + with closing(self.br.open(result.cover_url, timeout=timeout)) as f: + result.cover_data = f.read() + result.cover_data = thumbnail(result.cover_data, 64, 64)[2] + callback() + self.tasks.task_done() + except: + continue + + +class DetailsThreadPool(GenericDownloadThreadPool): + ''' + Once started all threads run until abort is called. + ''' + + def add_task(self, search_result, store_plugin, update_callback, timeout=10): + self.tasks.put((search_result, store_plugin, update_callback, timeout)) + + +class DetailsThread(Thread): + + def __init__(self, tasks, results): + Thread.__init__(self) + self.daemon = True + self.tasks = tasks + self.results = results + self._run = True + + def abort(self): + self._run = False + + def run(self): + while self._run: + try: + time.sleep(.1) + while not self.tasks.empty(): + if not self._run: + break + result, store_plugin, callback, timeout = self.tasks.get() + if result: + store_plugin.get_details(result, timeout) + callback(result) + self.tasks.task_done() + except: + continue diff --git a/src/calibre/gui2/store/search/models.py b/src/calibre/gui2/store/search/models.py new file mode 100644 index 0000000000..eadbd36a9b --- /dev/null +++ b/src/calibre/gui2/store/search/models.py @@ -0,0 +1,331 @@ +# -*- coding: utf-8 -*- + +from __future__ import (unicode_literals, division, absolute_import, print_function) + +__license__ = 'GPL 3' +__copyright__ = '2011, John Schember ' +__docformat__ = 'restructuredtext en' + +import re +from operator import attrgetter + +from PyQt4.Qt import (Qt, QAbstractItemModel, QVariant, QPixmap, QModelIndex, QSize) + +from calibre.gui2 import NONE +from calibre.gui2.store.search_result import SearchResult +from calibre.gui2.store.search.download_thread import DetailsThreadPool, \ + DetailsThread, CoverThreadPool, CoverThread +from calibre.library.caches import _match, CONTAINS_MATCH, EQUALS_MATCH, \ + REGEXP_MATCH +from calibre.utils.icu import sort_key +from calibre.utils.search_query_parser import SearchQueryParser + +def comparable_price(text): + if len(text) < 3 or text[-3] not in ('.', ','): + text += '00' + text = re.sub(r'\D', '', text) + text = text.rjust(6, '0') + return text + + +class Matches(QAbstractItemModel): + + HEADERS = [_('Cover'), _('Title'), _('Price'), _('DRM'), _('Store')] + HTML_COLS = (1, 4) + + def __init__(self): + QAbstractItemModel.__init__(self) + + self.DRM_LOCKED_ICON = QPixmap(I('drm-locked.png')).scaledToHeight(64, + Qt.SmoothTransformation) + self.DRM_UNLOCKED_ICON = QPixmap(I('drm-unlocked.png')).scaledToHeight(64, + Qt.SmoothTransformation) + self.DRM_UNKNOWN_ICON = QPixmap(I('dialog_question.png')).scaledToHeight(64, + Qt.SmoothTransformation) + + # All matches. Used to determine the order to display + # self.matches because the SearchFilter returns + # matches unordered. + self.all_matches = [] + # Only the showing matches. + self.matches = [] + self.query = '' + self.search_filter = SearchFilter() + self.cover_pool = CoverThreadPool(CoverThread, 2) + self.cover_pool.start_threads() + self.details_pool = DetailsThreadPool(DetailsThread, 4) + self.details_pool.start_threads() + + self.sort_col = 2 + self.sort_order = Qt.AscendingOrder + + def closing(self): + self.cover_pool.abort() + self.details_pool.abort() + + def clear_results(self): + self.all_matches = [] + self.matches = [] + self.all_matches = [] + self.search_filter.clear_search_results() + self.query = '' + self.cover_pool.abort() + self.cover_pool.start_threads() + self.details_pool.abort() + self.details_pool.start_threads() + self.reset() + + def add_result(self, result, store_plugin): + if result not in self.all_matches: + self.layoutAboutToBeChanged.emit() + self.all_matches.append(result) + self.search_filter.add_search_result(result) + if result.cover_url: + result.cover_queued = True + self.cover_pool.add_task(result, self.filter_results) + else: + result.cover_queued = False + self.details_pool.add_task(result, store_plugin, self.got_result_details) + self.filter_results() + self.layoutChanged.emit() + + def get_result(self, index): + row = index.row() + if row < len(self.matches): + return self.matches[row] + else: + return None + + def has_results(self): + return len(self.matches) > 0 + + def filter_results(self): + self.layoutAboutToBeChanged.emit() + if self.query: + self.matches = list(self.search_filter.parse(self.query)) + else: + self.matches = list(self.search_filter.universal_set()) + self.sort(self.sort_col, self.sort_order, False) + self.layoutChanged.emit() + + def got_result_details(self, result): + if not result.cover_queued and result.cover_url: + result.cover_queued = True + self.cover_pool.add_task(result, self.filter_results) + if result in self.matches: + row = self.matches.index(result) + self.dataChanged.emit(self.index(row, 0), self.index(row, self.columnCount() - 1)) + if result.drm not in (SearchResult.DRM_LOCKED, SearchResult.DRM_UNLOCKED, SearchResult.DRM_UNKNOWN): + result.drm = SearchResult.DRM_UNKNOWN + self.filter_results() + + def set_query(self, query): + self.query = query + + def index(self, row, column, parent=QModelIndex()): + return self.createIndex(row, column) + + def parent(self, index): + if not index.isValid() or index.internalId() == 0: + return QModelIndex() + return self.createIndex(0, 0) + + def rowCount(self, *args): + return len(self.matches) + + def columnCount(self, *args): + return len(self.HEADERS) + + def headerData(self, section, orientation, role): + if role != Qt.DisplayRole: + return NONE + text = '' + if orientation == Qt.Horizontal: + if section < len(self.HEADERS): + text = self.HEADERS[section] + return QVariant(text) + else: + return QVariant(section+1) + + def data(self, index, role): + row, col = index.row(), index.column() + result = self.matches[row] + if role == Qt.DisplayRole: + if col == 1: + t = result.title if result.title else _('Unknown') + a = result.author if result.author else '' + return QVariant('%s
%s' % (t, a)) + elif col == 2: + return QVariant(result.price) + elif col == 4: + return QVariant('%s
%s' % (result.store_name, result.formats)) + return NONE + elif role == Qt.DecorationRole: + if col == 0 and result.cover_data: + p = QPixmap() + p.loadFromData(result.cover_data) + return QVariant(p) + if col == 3: + if result.drm == SearchResult.DRM_LOCKED: + return QVariant(self.DRM_LOCKED_ICON) + elif result.drm == SearchResult.DRM_UNLOCKED: + return QVariant(self.DRM_UNLOCKED_ICON) + elif result.drm == SearchResult.DRM_UNKNOWN: + return QVariant(self.DRM_UNKNOWN_ICON) + elif role == Qt.ToolTipRole: + if col == 1: + return QVariant('

%s

' % result.title) + elif col == 2: + return QVariant('

' + _('Detected price as: %s. Check with the store before making a purchase to verify this price is correct. This price often does not include promotions the store may be running.') % result.price + '

') + elif col == 3: + if result.drm == SearchResult.DRM_LOCKED: + return QVariant('

' + _('This book as been detected as having DRM restrictions. This book may not work with your reader and you will have limitations placed upon you as to what you can do with this book. Check with the store before making any purchases to ensure you can actually read this book.') + '

') + elif result.drm == SearchResult.DRM_UNLOCKED: + return QVariant('

' + _('This book has been detected as being DRM Free. You should be able to use this book on any device provided it is in a format calibre supports for conversion. However, before making a purchase double check the DRM status with the store. The store may not be disclosing the use of DRM.') + '

') + else: + return QVariant('

' + _('The DRM status of this book could not be determined. There is a very high likelihood that this book is actually DRM restricted.') + '

') + elif col == 4: + return QVariant('

%s

' % result.formats) + elif role == Qt.SizeHintRole: + return QSize(64, 64) + return NONE + + def data_as_text(self, result, col): + text = '' + if col == 1: + text = result.title + elif col == 2: + text = comparable_price(result.price) + elif col == 3: + if result.drm == SearchResult.DRM_UNLOCKED: + text = 'a' + if result.drm == SearchResult.DRM_LOCKED: + text = 'b' + else: + text = 'c' + elif col == 4: + text = result.store_name + return text + + def sort(self, col, order, reset=True): + self.sort_col = col + self.sort_order = order + if not self.matches: + return + descending = order == Qt.DescendingOrder + self.all_matches.sort(None, + lambda x: sort_key(unicode(self.data_as_text(x, col))), + descending) + self.reorder_matches() + if reset: + self.reset() + + def reorder_matches(self): + self.matches = sorted(self.matches, key=lambda x: self.all_matches.index(x)) + + +class SearchFilter(SearchQueryParser): + + USABLE_LOCATIONS = [ + 'all', + 'author', + 'authors', + 'cover', + 'drm', + 'format', + 'formats', + 'price', + 'title', + 'store', + ] + + def __init__(self): + SearchQueryParser.__init__(self, locations=self.USABLE_LOCATIONS) + self.srs = set([]) + + def add_search_result(self, search_result): + self.srs.add(search_result) + + def clear_search_results(self): + self.srs = set([]) + + def universal_set(self): + return self.srs + + def get_matches(self, location, query): + location = location.lower().strip() + if location == 'authors': + location = 'author' + elif location == 'formats': + location = 'format' + + matchkind = CONTAINS_MATCH + if len(query) > 1: + if query.startswith('\\'): + query = query[1:] + elif query.startswith('='): + matchkind = EQUALS_MATCH + query = query[1:] + elif query.startswith('~'): + matchkind = REGEXP_MATCH + query = query[1:] + if matchkind != REGEXP_MATCH: ### leave case in regexps because it can be significant e.g. \S \W \D + query = query.lower() + + if location not in self.USABLE_LOCATIONS: + return set([]) + matches = set([]) + all_locs = set(self.USABLE_LOCATIONS) - set(['all']) + locations = all_locs if location == 'all' else [location] + q = { + 'author': lambda x: x.author.lower(), + 'cover': attrgetter('cover_url'), + 'drm': attrgetter('drm'), + 'format': attrgetter('formats'), + 'price': lambda x: comparable_price(x.price), + 'store': lambda x: x.store_name.lower(), + 'title': lambda x: x.title.lower(), + } + for x in ('author', 'format'): + q[x+'s'] = q[x] + for sr in self.srs: + for locvalue in locations: + accessor = q[locvalue] + if query == 'true': + if locvalue == 'drm': + if accessor(sr) == SearchResult.DRM_LOCKED: + matches.add(sr) + else: + if accessor(sr) is not None: + matches.add(sr) + continue + if query == 'false': + if locvalue == 'drm': + if accessor(sr) == SearchResult.DRM_UNLOCKED: + matches.add(sr) + else: + if accessor(sr) is None: + matches.add(sr) + continue + # this is bool, so can't match below + if locvalue == 'drm': + continue + try: + ### Can't separate authors because comma is used for name sep and author sep + ### Exact match might not get what you want. For that reason, turn author + ### exactmatch searches into contains searches. + if locvalue == 'author' and matchkind == EQUALS_MATCH: + m = CONTAINS_MATCH + else: + m = matchkind + + if locvalue == 'format': + vals = accessor(sr).split(',') + else: + vals = [accessor(sr)] + if _match(query, vals, m): + matches.add(sr) + break + except ValueError: # Unicode errors + traceback.print_exc() + return matches diff --git a/src/calibre/gui2/store/search/results_view.py b/src/calibre/gui2/store/search/results_view.py new file mode 100644 index 0000000000..cfbdc721ef --- /dev/null +++ b/src/calibre/gui2/store/search/results_view.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- + +from __future__ import (unicode_literals, division, absolute_import, print_function) + +__license__ = 'GPL 3' +__copyright__ = '2011, John Schember ' +__docformat__ = 'restructuredtext en' + +from PyQt4.Qt import (QTreeView) + +from calibre.gui2.metadata.single_download import RichTextDelegate +from calibre.gui2.store.search.models import Matches + +class ResultsView(QTreeView): + + def __init__(self, *args): + QTreeView.__init__(self,*args) + + self._model = Matches() + self.setModel(self._model) + + self.rt_delegate = RichTextDelegate(self) + + for i in self._model.HTML_COLS: + self.setItemDelegateForColumn(i, self.rt_delegate) + diff --git a/src/calibre/gui2/store/search/search.py b/src/calibre/gui2/store/search/search.py new file mode 100644 index 0000000000..5c4b1cee00 --- /dev/null +++ b/src/calibre/gui2/store/search/search.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- + +from __future__ import (unicode_literals, division, absolute_import, print_function) + +__license__ = 'GPL 3' +__copyright__ = '2011, John Schember ' +__docformat__ = 'restructuredtext en' + +import re +from random import shuffle + +from PyQt4.Qt import (Qt, QDialog, QTimer, QCheckBox, QVBoxLayout) + +from calibre.gui2 import JSONConfig, info_dialog +from calibre.gui2.progress_indicator import ProgressIndicator +from calibre.gui2.store.search.download_thread import SearchThreadPool, SearchThread +from calibre.gui2.store.search.search_ui import Ui_Dialog + +HANG_TIME = 75000 # milliseconds seconds +TIMEOUT = 75 # seconds +SEARCH_THREAD_TOTAL = 4 +COVER_DOWNLOAD_THREAD_TOTAL = 2 + +class SearchDialog(QDialog, Ui_Dialog): + + def __init__(self, istores, *args): + QDialog.__init__(self, *args) + self.setupUi(self) + + self.config = JSONConfig('store/search') + + # We keep a cache of store plugins and reference them by name. + self.store_plugins = istores + self.search_pool = SearchThreadPool(SearchThread, SEARCH_THREAD_TOTAL) + # Check for results and hung threads. + self.checker = QTimer() + self.hang_check = 0 + + # Add check boxes for each store so the user + # can disable searching specific stores on a + # per search basis. + stores_group_layout = QVBoxLayout() + self.stores_group.setLayout(stores_group_layout) + for x in self.store_plugins: + cbox = QCheckBox(x) + cbox.setChecked(True) + stores_group_layout.addWidget(cbox) + setattr(self, 'store_check_' + x, cbox) + stores_group_layout.addStretch() + + # Create and add the progress indicator + self.pi = ProgressIndicator(self, 24) + self.top_layout.addWidget(self.pi) + + self.search.clicked.connect(self.do_search) + self.checker.timeout.connect(self.get_results) + self.results_view.activated.connect(self.open_store) + self.select_all_stores.clicked.connect(self.stores_select_all) + self.select_invert_stores.clicked.connect(self.stores_select_invert) + self.select_none_stores.clicked.connect(self.stores_select_none) + self.finished.connect(self.dialog_closed) + + self.restore_state() + + def resize_columns(self): + total = 600 + # Cover + self.results_view.setColumnWidth(0, 85) + total = total - 85 + # Title / Author + self.results_view.setColumnWidth(1,int(total*.40)) + # Price + self.results_view.setColumnWidth(2,int(total*.20)) + # DRM + self.results_view.setColumnWidth(3, int(total*.15)) + # Store / Formats + self.results_view.setColumnWidth(4, int(total*.25)) + + def do_search(self, checked=False): + # Stop all running threads. + self.checker.stop() + self.search_pool.abort() + # Clear the visible results. + self.results_view.model().clear_results() + + # Don't start a search if there is nothing to search for. + query = unicode(self.search_edit.text()) + if not query.strip(): + return + # Give the query to the results model so it can do + # futher filtering. + self.results_view.model().set_query(query) + + # Plugins are in alphebetic order. Randomize the + # order of plugin names. This way plugins closer + # to a don't have an unfair advantage over + # plugins further from a. + store_names = self.store_plugins.keys() + if not store_names: + return + # Remove all of our internal filtering logic from the query. + query = self.clean_query(query) + shuffle(store_names) + # Add plugins that the user has checked to the search pool's work queue. + for n in store_names: + if getattr(self, 'store_check_' + n).isChecked(): + self.search_pool.add_task(query, n, self.store_plugins[n], TIMEOUT) + if self.search_pool.has_tasks(): + self.hang_check = 0 + self.checker.start(100) + self.search_pool.start_threads() + self.pi.startAnimation() + + def clean_query(self, query): + query = query.lower() + # Remove control modifiers. + query = query.replace('\\', '') + query = query.replace('!', '') + query = query.replace('=', '') + query = query.replace('~', '') + query = query.replace('>', '') + query = query.replace('<', '') + # Remove the prefix. + for loc in ( 'all', 'author', 'authors', 'title'): + query = re.sub(r'%s:"?(?P
[^\s"]+)"?' % loc, '\g', query) + # Remove the prefix and search text. + for loc in ('cover', 'drm', 'format', 'formats', 'price', 'store'): + query = re.sub(r'%s:"[^"]"' % loc, '', query) + query = re.sub(r'%s:[^\s]*' % loc, '', query) + # Remove logic. + query = re.sub(r'(^|\s)(and|not|or)(\s|$)', ' ', query) + # Remove excess whitespace. + query = re.sub(r'\s{2,}', ' ', query) + query = query.strip() + return query + + def save_state(self): + self.config['geometry'] = bytearray(self.saveGeometry()) + self.config['store_splitter_state'] = bytearray(self.store_splitter.saveState()) + self.config['results_view_column_width'] = [self.results_view.columnWidth(i) for i in range(self.results_view.model().columnCount())] + self.config['sort_col'] = self.results_view.model().sort_col + self.config['sort_order'] = self.results_view.model().sort_order + + store_check = {} + for n in self.store_plugins: + store_check[n] = getattr(self, 'store_check_' + n).isChecked() + self.config['store_checked'] = store_check + + def restore_state(self): + geometry = self.config.get('geometry', None) + if geometry: + self.restoreGeometry(geometry) + + splitter_state = self.config.get('store_splitter_state', None) + if splitter_state: + self.store_splitter.restoreState(splitter_state) + + results_cwidth = self.config.get('results_view_column_width', None) + if results_cwidth: + for i, x in enumerate(results_cwidth): + if i >= self.results_view.model().columnCount(): + break + self.results_view.setColumnWidth(i, x) + else: + self.resize_columns() + + store_check = self.config.get('store_checked', None) + if store_check: + for n in store_check: + if hasattr(self, 'store_check_' + n): + getattr(self, 'store_check_' + n).setChecked(store_check[n]) + + self.results_view.model().sort_col = self.config.get('sort_col', 2) + self.results_view.model().sort_order = self.config.get('sort_order', Qt.AscendingOrder) + self.results_view.header().setSortIndicator(self.results_view.model().sort_col, self.results_view.model().sort_order) + + def get_results(self): + # We only want the search plugins to run + # a maximum set amount of time before giving up. + self.hang_check += 1 + if self.hang_check >= HANG_TIME: + self.search_pool.abort() + self.checker.stop() + self.pi.stopAnimation() + else: + # Stop the checker if not threads are running. + if not self.search_pool.threads_running() and not self.search_pool.has_tasks(): + self.checker.stop() + self.pi.stopAnimation() + + while self.search_pool.has_results(): + res, store_plugin = self.search_pool.get_result() + if res: + self.results_view.model().add_result(res, store_plugin) + + if not self.checker.isActive(): + if not self.results_view.model().has_results(): + info_dialog(self, _('No matches'), _('Couldn\'t find any books matching your query.'), show=True, show_copy_button=False) + + + def open_store(self, index): + result = self.results_view.model().get_result(index) + self.store_plugins[result.store_name].open(self, result.detail_item) + + def get_store_checks(self): + ''' + Returns a list of QCheckBox's for each store. + ''' + checks = [] + for x in self.store_plugins: + check = getattr(self, 'store_check_' + x, None) + if check: + checks.append(check) + return checks + + def stores_select_all(self): + for check in self.get_store_checks(): + check.setChecked(True) + + def stores_select_invert(self): + for check in self.get_store_checks(): + check.setChecked(not check.isChecked()) + + def stores_select_none(self): + for check in self.get_store_checks(): + check.setChecked(False) + + def dialog_closed(self, result): + self.results_view.model().closing() + self.search_pool.abort() + self.save_state() + diff --git a/src/calibre/gui2/store/search.ui b/src/calibre/gui2/store/search/search.ui similarity index 93% rename from src/calibre/gui2/store/search.ui rename to src/calibre/gui2/store/search/search.ui index dd7db939a4..bdf875113e 100644 --- a/src/calibre/gui2/store/search.ui +++ b/src/calibre/gui2/store/search/search.ui @@ -14,7 +14,7 @@ Get Books - + :/images/store.png:/images/store.png @@ -22,7 +22,7 @@ - + @@ -62,8 +62,8 @@ 0 0 - 170 - 138 + 215 + 116 @@ -110,7 +110,7 @@ Qt::Horizontal - + 1 @@ -178,6 +178,13 @@ + + + ResultsView + QTreeView +
results_view.h
+
+
diff --git a/src/calibre/gui2/store/smashwords_plugin.py b/src/calibre/gui2/store/smashwords_plugin.py index 629a85dca5..73700ed546 100644 --- a/src/calibre/gui2/store/smashwords_plugin.py +++ b/src/calibre/gui2/store/smashwords_plugin.py @@ -25,7 +25,6 @@ from calibre.gui2.store.web_store_dialog import WebStoreDialog class SmashwordsStore(BasicStoreConfig, StorePlugin): def open(self, parent=None, detail_item=None, external=False): - settings = self.get_settings() url = 'http://www.smashwords.com/' aff_id = '?ref=usernone' @@ -38,12 +37,12 @@ class SmashwordsStore(BasicStoreConfig, StorePlugin): detail_url = url + detail_item + aff_id url = url + aff_id - if external or settings.get(self.name + '_open_external', False): + if external or self.config.get('open_external', False): open_url(QUrl(url_slash_cleaner(detail_url if detail_url else url))) else: d = WebStoreDialog(self.gui, url, parent, detail_url) d.setWindowTitle(self.name) - d.set_tags(settings.get(self.name + '_tags', '')) + d.set_tags(self.config.get('tags', '')) d.exec_() def search(self, query, max_results=10, timeout=60):