diff --git a/src/calibre/gui2/store/mobileread_plugin.py b/src/calibre/gui2/store/mobileread_plugin.py index 25125d38c0..3547eb555c 100644 --- a/src/calibre/gui2/store/mobileread_plugin.py +++ b/src/calibre/gui2/store/mobileread_plugin.py @@ -10,12 +10,13 @@ import difflib import heapq import time from contextlib import closing +from operator import attrgetter from threading import RLock from lxml import html from PyQt4.Qt import Qt, QUrl, QDialog, QAbstractItemModel, QModelIndex, QVariant, \ - pyqtSignal + pyqtSignal, QIcon from calibre import browser from calibre.gui2 import open_url, NONE @@ -24,7 +25,11 @@ from calibre.gui2.store.basic_config import BasicStoreConfig from calibre.gui2.store.mobileread_store_dialog_ui import Ui_Dialog from calibre.gui2.store.search_result import SearchResult from calibre.gui2.store.web_store_dialog import WebStoreDialog +from calibre.gui2.store.search.adv_search_builder import AdvSearchBuilderDialog +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 class MobileReadStore(BasicStoreConfig, StorePlugin): @@ -50,28 +55,10 @@ class MobileReadStore(BasicStoreConfig, StorePlugin): def search(self, query, max_results=10, timeout=60): books = self.get_book_list(timeout=timeout) - query = query.lower() - query_parts = query.split(' ') - matches = [] - s = difflib.SequenceMatcher() - for x in books: - ratio = 0 - t_string = '%s %s' % (x.author.lower(), x.title.lower()) - query_matches = [] - for q in query_parts: - if q in t_string: - query_matches.append(q) - for q in query_matches: - s.set_seq2(q) - for p in t_string.split(' '): - s.set_seq1(p) - ratio += s.ratio() - if ratio > 0: - matches.append((ratio, x)) - - # Move the best scorers to head of list. - matches = heapq.nlargest(max_results, matches) - for score, book in matches: + sf = SearchFilter(books) + matches = sf.parse(query) + + for book in matches: book.price = '$0.00' book.drm = SearchResult.DRM_UNLOCKED yield book @@ -153,23 +140,38 @@ class MobeReadStoreDialog(QDialog, Ui_Dialog): self.plugin = plugin - self.model = BooksModel() - self.results_view.setModel(self.model) - self.results_view.model().set_books(self.plugin.get_book_list()) - self.total.setText('%s' % self.model.rowCount()) + self.adv_search_button.setIcon(QIcon(I('search.png'))) + + self._model = BooksModel(self.plugin.get_book_list()) + self.results_view.setModel(self._model) + self.total.setText('%s' % self.results_view.model().rowCount()) + self.search_button.clicked.connect(self.do_search) + self.adv_search_button.clicked.connect(self.build_adv_search) self.results_view.activated.connect(self.open_store) - self.search_query.textChanged.connect(self.model.set_filter) - self.results_view.model().total_changed.connect(self.total.setText) + self.results_view.model().total_changed.connect(self.update_book_total) self.finished.connect(self.dialog_closed) self.restore_state() + def do_search(self): + self.results_view.model().search(unicode(self.search_query.text())) + def open_store(self, index): result = self.results_view.model().get_book(index) if result: self.plugin.open(self, result.detail_item) + def update_book_total(self, total): + self.total.setText('%s' % total) + + def build_adv_search(self): + adv = AdvSearchBuilderDialog(self) + adv.price_label.hide() + adv.price_box.hide() + if adv.exec_() == QDialog.Accepted: + self.search_query.setText(adv.search_string()) + def restore_state(self): geometry = self.plugin.config.get('dialog_geometry', None) if geometry: @@ -192,7 +194,7 @@ class MobeReadStoreDialog(QDialog, Ui_Dialog): def save_state(self): self.plugin.config['dialog_geometry'] = bytearray(self.saveGeometry()) - self.plugin.config['dialog_results_view_column_width'] = [self.results_view.columnWidth(i) for i in range(self.model.columnCount())] + self.plugin.config['dialog_results_view_column_width'] = [self.results_view.columnWidth(i) for i in range(self.results_view.model().columnCount())] self.plugin.config['dialog_sort_col'] = self.results_view.model().sort_col self.plugin.config['dialog_sort_order'] = self.results_view.model().sort_order @@ -202,24 +204,19 @@ class MobeReadStoreDialog(QDialog, Ui_Dialog): class BooksModel(QAbstractItemModel): - total_changed = pyqtSignal(unicode) + total_changed = pyqtSignal(int) HEADERS = [_('Title'), _('Author(s)'), _('Format')] - def __init__(self): + def __init__(self, all_books): QAbstractItemModel.__init__(self) - self.books = [] - self.all_books = [] + self.books = all_books + self.all_books = all_books self.filter = '' + self.search_filter = SearchFilter(all_books) self.sort_col = 0 self.sort_order = Qt.AscendingOrder - def set_books(self, books): - self.books = books - self.all_books = books - - self.sort(self.sort_col, self.sort_order) - def get_book(self, index): row = index.row() if row < len(self.books): @@ -227,32 +224,17 @@ class BooksModel(QAbstractItemModel): else: return None - def set_filter(self, filter): - #self.layoutAboutToBeChanged.emit() - self.beginResetModel() - - self.filter = unicode(filter) - self.books = [] - if self.filter: - for b in self.all_books: - test = '%s %s %s' % (b.title, b.author, b.formats) - test = test.lower() - include = True - for item in self.filter.split(' '): - item = item.lower() - if item not in test: - include = False - break - if include: - self.books.append(b) - else: + def search(self, filter): + self.filter = filter.strip() + if not self.filter: self.books = self.all_books - - self.sort(self.sort_col, self.sort_order, reset=False) - self.total_changed.emit('%s' % self.rowCount()) - - self.endResetModel() - #self.layoutChanged.emit() + else: + try: + self.books = list(self.search_filter.parse(self.filter)) + except: + self.books = self.all_books + self.sort(self.sort_col, self.sort_order) + self.total_changed.emit(self.rowCount()) def index(self, row, column, parent=QModelIndex()): return self.createIndex(row, column) @@ -304,13 +286,91 @@ class BooksModel(QAbstractItemModel): def sort(self, col, order, reset=True): self.sort_col = col self.sort_order = order - if not self.books: return - descending = order == Qt.DescendingOrder + descending = order == Qt.DescendingOrder self.books.sort(None, lambda x: sort_key(unicode(self.data_as_text(x, col))), descending) if reset: self.reset() + +class SearchFilter(SearchQueryParser): + + USABLE_LOCATIONS = [ + 'all', + 'author', + 'authors', + 'format', + 'formats', + 'title', + ] + + def __init__(self, all_books=[]): + SearchQueryParser.__init__(self, locations=self.USABLE_LOCATIONS) + self.srs = set(all_books) + + 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(), + 'format': attrgetter('formats'), + '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 accessor(sr) is not None: + matches.add(sr) + continue + if query == 'false': + if accessor(sr) is None: + matches.add(sr) + 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 + + vals = [accessor(sr)] + if _match(query, vals, m): + matches.add(sr) + break + except ValueError: # Unicode errors + import traceback + traceback.print_exc() + return matches diff --git a/src/calibre/gui2/store/mobileread_store_dialog.ui b/src/calibre/gui2/store/mobileread_store_dialog.ui index 027d5994f0..6d31efab6d 100644 --- a/src/calibre/gui2/store/mobileread_store_dialog.ui +++ b/src/calibre/gui2/store/mobileread_store_dialog.ui @@ -19,13 +19,30 @@ - Search: + &Query: + + + search_query + + + + + + + ... + + + + Search + + + diff --git a/src/calibre/gui2/store/search/adv_search_builder.ui b/src/calibre/gui2/store/search/adv_search_builder.ui index 576f7d3337..a758057311 100644 --- a/src/calibre/gui2/store/search/adv_search_builder.ui +++ b/src/calibre/gui2/store/search/adv_search_builder.ui @@ -50,7 +50,7 @@ - 1 + 0 @@ -217,7 +217,7 @@ - + &Price: