diff --git a/src/calibre/gui2/dialogs/search.py b/src/calibre/gui2/dialogs/search.py
index f41a80e620..7551483afb 100644
--- a/src/calibre/gui2/dialogs/search.py
+++ b/src/calibre/gui2/dialogs/search.py
@@ -5,21 +5,30 @@ from PyQt4.QtGui import QDialog
from calibre.gui2.dialogs.search_ui import Ui_Dialog
from calibre.gui2 import qstring_to_unicode
-
+from calibre.library.database2 import CONTAINS_MATCH, EQUALS_MATCH, REGEXP_MATCH
class SearchDialog(QDialog, Ui_Dialog):
def __init__(self, *args):
QDialog.__init__(self, *args)
self.setupUi(self)
+ self.mc = ''
def tokens(self, raw):
- phrases = re.findall(r'\s+".*?"\s+', raw)
+ phrases = re.findall(r'\s*".*?"\s*', raw)
for f in phrases:
raw = raw.replace(f, ' ')
- return [t.strip() for t in phrases + raw.split()]
+ phrases = [t.strip('" ') for t in phrases]
+ return ['"' + self.mc + t + '"' for t in phrases + [r.strip() for r in raw.split()]]
def search_string(self):
+ mk = self.matchkind.currentIndex()
+ if mk == CONTAINS_MATCH:
+ self.mc = ''
+ elif mk == EQUALS_MATCH:
+ self.mc = '='
+ else:
+ self.mc = '~'
all, any, phrase, none = map(lambda x: unicode(x.text()),
(self.all, self.any, self.phrase, self.none))
all, any, none = map(self.tokens, (all, any, none))
diff --git a/src/calibre/gui2/dialogs/search.ui b/src/calibre/gui2/dialogs/search.ui
index bbd7411583..dc66aae6a9 100644
--- a/src/calibre/gui2/dialogs/search.ui
+++ b/src/calibre/gui2/dialogs/search.ui
@@ -104,7 +104,64 @@
-
-
+
+
+
+ 16777215
+ 60
+
+
+
+
-
+
+
+ What kind of match to use:
+
+
+ matchkind
+
+
+
+ -
+
+
-
+
+ Contains: the word or phrase matches anywhere in the metadata
+
+
+ -
+
+ Equals: the word or phrase must match an entire metadata field
+
+
+ -
+
+ Regular expression: the expression must match anywhere in the metadata
+
+
+
+
+ -
+
+
+
+ 40
+ 0
+
+
+
+
+
+
+ matchkind
+
+
+
+
+
+
+ -
+
16777215
diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py
index 85a1cd805c..fe8eca8ead 100644
--- a/src/calibre/gui2/library.py
+++ b/src/calibre/gui2/library.py
@@ -17,7 +17,7 @@ from PyQt4.QtCore import QAbstractTableModel, QVariant, Qt, pyqtSignal, \
from calibre import strftime
from calibre.ptempfile import PersistentTemporaryFile
from calibre.utils.pyparsing import ParseException
-from calibre.library.database2 import FIELD_MAP
+from calibre.library.database2 import FIELD_MAP, _match, CONTAINS_MATCH, EQUALS_MATCH, REGEXP_MATCH
from calibre.gui2 import NONE, TableView, qstring_to_unicode, config, \
error_dialog
from calibre.gui2.widgets import EnLineEdit, TagsLineEdit
@@ -893,7 +893,20 @@ class OnDeviceSearch(SearchQueryParser):
def get_matches(self, location, query):
location = location.lower().strip()
- query = query.lower().strip()
+
+ 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 ('title', 'author', 'tag', 'all', 'format'):
return set([])
matches = set([])
@@ -904,13 +917,24 @@ class OnDeviceSearch(SearchQueryParser):
'tag':lambda x: ','.join(getattr(x, 'tags')).lower(),
'format':lambda x: os.path.splitext(x.path)[1].lower()
}
- for i, v in enumerate(locations):
- locations[i] = q[v]
- for i, r in enumerate(self.model.db):
- for loc in locations:
+ for index, row in enumerate(self.model.db):
+ for locvalue in locations:
+ accessor = q[locvalue]
try:
- if query in loc(r):
- matches.add(i)
+ ### 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 == 'tag':
+ vals = accessor(row).split(',')
+ else:
+ vals = [accessor(row)]
+ if _match(query, vals, m):
+ matches.add(index)
break
except ValueError: # Unicode errors
import traceback
diff --git a/src/calibre/gui2/tag_view.py b/src/calibre/gui2/tag_view.py
index 8ad0dff4d2..6d4e0d8655 100644
--- a/src/calibre/gui2/tag_view.py
+++ b/src/calibre/gui2/tag_view.py
@@ -173,6 +173,8 @@ class TagsModel(QAbstractItemModel):
if len(data[r]) > 0:
self.beginInsertRows(category_index, 0, len(data[r])-1)
for tag in data[r]:
+ if r == 'author':
+ tag.name = tag.name.replace('|', ',')
tag.state = state_map.get(tag.name, 0)
t = TagTreeItem(parent=category, data=tag, icon_map=self.icon_map)
self.endInsertRows()
@@ -278,7 +280,7 @@ class TagsModel(QAbstractItemModel):
category = key if key != 'news' else 'tag'
if tag.state > 0:
prefix = ' not ' if tag.state == 2 else ''
- ans.append('%s%s:"%s"'%(prefix, category, tag.name))
+ ans.append('%s%s:"=%s"'%(prefix, category, tag.name))
return ans
diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py
index 2a43f39f43..f3b5e439a3 100644
--- a/src/calibre/gui2/ui.py
+++ b/src/calibre/gui2/ui.py
@@ -669,19 +669,19 @@ class Main(MainWindow, Ui_MainWindow, DeviceGUI):
if type == 'series':
series = idx.model().db.series(row)
if series:
- search = ['series:'+series]
+ search = ['series:"'+series+'"']
elif type == 'publisher':
publisher = idx.model().db.publisher(row)
if publisher:
- search = ['publisher:'+publisher]
+ search = ['publisher:"'+publisher+'"']
elif type == 'tag':
tags = idx.model().db.tags(row)
if tags:
- search = ['tag:'+t for t in tags.split(',')]
+ search = ['tag:"='+t+'"' for t in tags.split(',')]
elif type == 'author':
authors = idx.model().db.authors(row)
if authors:
- search = ['author:'+a.strip().replace('|', ',') \
+ search = ['author:"='+a.strip().replace('|', ',')+'"' \
for a in authors.split(',')]
join = ' or '
if search:
diff --git a/src/calibre/library/database2.py b/src/calibre/library/database2.py
index ac412aacfb..d1a0c24cef 100644
--- a/src/calibre/library/database2.py
+++ b/src/calibre/library/database2.py
@@ -174,6 +174,22 @@ class CoverCache(QThread):
self.load_queue.appendleft(id)
self.load_queue_lock.unlock()
+### Global utility function for get_match here and in gui2/library.py
+CONTAINS_MATCH = 0
+EQUALS_MATCH = 1
+REGEXP_MATCH = 2
+def _match(query, value, matchkind):
+ for t in value:
+ t = t.lower()
+ try: ### ignore regexp exceptions, required because search-ahead tries before typing is finished
+ if ((matchkind == EQUALS_MATCH and query == t) or
+ (matchkind == REGEXP_MATCH and re.search(query, t, re.I)) or ### search unanchored
+ (matchkind == CONTAINS_MATCH and query in t)):
+ return True
+ except re.error:
+ pass
+ return False
+
class ResultCache(SearchQueryParser):
'''
@@ -202,10 +218,23 @@ class ResultCache(SearchQueryParser):
matches = set([])
if query and query.strip():
location = location.lower().strip()
- query = query.lower()
+
+ 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 not isinstance(query, unicode):
query = query.decode('utf-8')
- if location in ('tag', 'author', 'format'):
+ if location in ('tag', 'author', 'format', 'comment'):
location += 's'
all = ('title', 'authors', 'publisher', 'tags', 'comments', 'series', 'formats', 'isbn', 'rating', 'cover')
MAP = {}
@@ -219,29 +248,41 @@ class ResultCache(SearchQueryParser):
rating_query = int(query) * 2
except:
rating_query = None
- for item in self._data:
- if item is None: continue
- for loc in location:
- if query == 'false' and not item[loc]:
- if isinstance(item[loc], basestring):
- if item[loc].strip() != '':
- continue
- matches.add(item[0])
- break
- if query == 'true' and item[loc]:
+ for loc in location:
+ if loc == MAP['authors']:
+ q = query.replace(',', '|'); ### DB stores authors with commas changed to bars, so change query
+ else:
+ q = query
+
+ for item in self._data:
+ if item is None: continue
+ if not item[loc]:
+ if query == 'false':
+ if isinstance(item[loc], basestring):
+ if item[loc].strip() != '':
+ continue
+ matches.add(item[0])
+ break
+ continue ### item is empty. No possible matches below
+
+ if q == 'true':
if isinstance(item[loc], basestring):
if item[loc].strip() == '':
continue
matches.add(item[0])
- break
- if rating_query and item[loc] and loc == MAP['rating'] and rating_query == int(item[loc]):
+ continue
+ if rating_query and loc == MAP['rating'] and rating_query == int(item[loc]):
matches.add(item[0])
- break
- if item[loc] and loc not in EXCLUDE_FIELDS and query in item[loc].lower():
- matches.add(item[0])
- break
-
- return matches
+ continue
+ if loc not in EXCLUDE_FIELDS:
+ if loc == MAP['tags'] or loc == MAP['authors']:
+ vals = item[loc].split(',') ### check individual tags/authors, not the long string
+ else:
+ vals = [item[loc]] ### make into list to make _match happy
+ if _match(q, vals, matchkind):
+ matches.add(item[0])
+ continue
+ return matches
def remove(self, id):
self._data[id] = None
diff --git a/src/calibre/manual/gui.rst b/src/calibre/manual/gui.rst
index 93b91e94ae..06906cdfb3 100644
--- a/src/calibre/manual/gui.rst
+++ b/src/calibre/manual/gui.rst
@@ -195,6 +195,15 @@ are available in the LRF format. Some more examples::
title:"The Ring" or "This book is about a ring"
format:epub publisher:feedbooks.com
+Searches are by default 'contains'. An item matches if the search string appears anywhere in the indicated metadata.
+Two other kinds of searches are available: equality search and search using regular expressions.
+
+Equality searches are indicated by prefixing the search string with an equals sign (=). For example, the query
+``tag:"=science"`` will match "science", but not "science fiction". Regular expression searches are
+indicated by prefixing the search string with a tilde (~). Any python-compatible regular expression can
+be used. Regular expression searches are contains searches unless the expression contains anchors.
+Should you need to search for a string with a leading equals or tilde, prefix the string with a backslash.
+
You can build advanced search queries easily using the :guilabel:`Advanced Search Dialog`, accessed by
clicking the button |sbi|.