diff --git a/ftl/core/browsing.ftl b/ftl/core/browsing.ftl index bc5110291..bf408f834 100644 --- a/ftl/core/browsing.ftl +++ b/ftl/core/browsing.ftl @@ -83,6 +83,7 @@ browsing-reposition = Reposition... browsing-reposition-new-cards = Reposition New Cards browsing-reschedule = Reschedule browsing-save-current-filter = Save Current Filter... +browsing-search-bar-hint = Type here and press Enter to search for cards and notes. browsing-search-in = Search in: browsing-search-within-formatting-slow = Search within formatting (slow) browsing-shift-position-of-existing-cards = Shift position of existing cards @@ -101,7 +102,6 @@ browsing-today = Today browsing-toggle-mark = Toggle Mark browsing-toggle-suspend = Toggle Suspend browsing-treat-input-as-regular-expression = Treat input as regular expression -browsing-type-here-to-search = browsing-whole-collection = Whole Collection browsing-you-must-have-at-least-one = You must have at least one column. browsing-group = diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index e8127a39e..e6278fafd 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -17,6 +17,7 @@ import anki.find import anki.latex # sets up hook import anki.template from anki import hooks +from anki.backend_pb2 import SearchTerm from anki.cards import Card from anki.config import ConfigManager from anki.consts import * @@ -26,18 +27,22 @@ from anki.errors import AnkiError from anki.media import MediaManager, media_paths_from_col_path from anki.models import ModelManager from anki.notes import Note -from anki.rsbackend import ( +from anki.rsbackend import ( # pylint: disable=unused-import TR, + BackendNoteTypeID, + ConcatSeparator, DBError, FormatTimeSpanContext, + InvalidInput, Progress, RustBackend, from_json_bytes, + pb, ) from anki.sched import Scheduler as V1Scheduler from anki.schedv2 import Scheduler as V2Scheduler from anki.tags import TagManager -from anki.utils import devMode, ids2str, intTime +from anki.utils import devMode, ids2str, intTime, splitFields, stripHTMLMedia ConfigBoolKey = pb.ConfigBool.Key # pylint: disable=no-member @@ -458,8 +463,8 @@ class Collection: ) return self.backend.search_cards(search=query, order=mode) - def find_notes(self, query: str) -> Sequence[int]: - return self.backend.search_notes(query) + def find_notes(self, *terms: Union[str, SearchTerm]) -> Sequence[int]: + return self.backend.search_notes(self.build_search_string(*terms)) def find_and_replace( self, @@ -472,13 +477,76 @@ class Collection: ) -> int: return anki.find.findReplace(self, nids, src, dst, regex, field, fold) + # returns array of ("dupestr", [nids]) def findDupes(self, fieldName: str, search: str = "") -> List[Tuple[Any, list]]: - return anki.find.findDupes(self, fieldName, search) + nids = self.findNotes(search, SearchTerm(field_name=fieldName)) + # go through notes + vals: Dict[str, List[int]] = {} + dupes = [] + fields: Dict[int, int] = {} + + def ordForMid(mid): + if mid not in fields: + model = self.models.get(mid) + for c, f in enumerate(model["flds"]): + if f["name"].lower() == fieldName.lower(): + fields[mid] = c + break + return fields[mid] + + for nid, mid, flds in self.db.all( + "select id, mid, flds from notes where id in " + ids2str(nids) + ): + flds = splitFields(flds) + ord = ordForMid(mid) + if ord is None: + continue + val = flds[ord] + val = stripHTMLMedia(val) + # empty does not count as duplicate + if not val: + continue + vals.setdefault(val, []).append(nid) + if len(vals[val]) == 2: + dupes.append((val, vals[val])) + return dupes findCards = find_cards findNotes = find_notes findReplace = find_and_replace + # Search Strings + ########################################################################## + + def build_search_string( + self, *terms: Union[str, SearchTerm], negate=False, match_any=False + ) -> str: + """Helper function for the backend's search string operations. + + Pass terms as strings to normalize. + Pass fields of backend.proto/FilterToSearchIn as valid SearchTerms. + Pass multiple terms to concatenate (defaults to 'and', 'or' when 'match_any=True'). + Pass 'negate=True' to negate the end result. + May raise InvalidInput. + """ + + searches = [] + for term in terms: + if isinstance(term, SearchTerm): + term = self.backend.filter_to_search(term) + searches.append(term) + if match_any: + sep = ConcatSeparator.OR + else: + sep = ConcatSeparator.AND + search_string = self.backend.concatenate_searches(sep=sep, searches=searches) + if negate: + search_string = self.backend.negate_search(search_string) + return search_string + + def replace_search_term(self, search: str, replacement: str) -> str: + return self.backend.replace_search_term(search=search, replacement=replacement) + # Config ########################################################################## diff --git a/pylib/anki/find.py b/pylib/anki/find.py index 33c3ca838..ff83d5e2e 100644 --- a/pylib/anki/find.py +++ b/pylib/anki/find.py @@ -6,7 +6,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, Optional, Set from anki.hooks import * -from anki.utils import ids2str, splitFields, stripHTMLMedia if TYPE_CHECKING: from anki.collection import Collection @@ -64,43 +63,3 @@ def fieldNames(col, downcase=True) -> List: if name not in fields: # slower w/o fields.add(name) return list(fields) - - -# returns array of ("dupestr", [nids]) -def findDupes( - col: Collection, fieldName: str, search: str = "" -) -> List[Tuple[Any, List]]: - # limit search to notes with applicable field name - if search: - search = "(" + search + ") " - search += '"%s:*"' % fieldName.replace('"', '"') - # go through notes - vals: Dict[str, List[int]] = {} - dupes = [] - fields: Dict[int, int] = {} - - def ordForMid(mid): - if mid not in fields: - model = col.models.get(mid) - for c, f in enumerate(model["flds"]): - if f["name"].lower() == fieldName.lower(): - fields[mid] = c - break - return fields[mid] - - for nid, mid, flds in col.db.all( - "select id, mid, flds from notes where id in " + ids2str(col.findNotes(search)) - ): - flds = splitFields(flds) - ord = ordForMid(mid) - if ord is None: - continue - val = flds[ord] - val = stripHTMLMedia(val) - # empty does not count as duplicate - if not val: - continue - vals.setdefault(val, []).append(nid) - if len(vals[val]) == 2: - dupes.append((val, vals[val])) - return dupes diff --git a/pylib/anki/rsbackend.py b/pylib/anki/rsbackend.py index 493272c0b..9ef91f6a5 100644 --- a/pylib/anki/rsbackend.py +++ b/pylib/anki/rsbackend.py @@ -50,9 +50,6 @@ TagTreeNode = pb.TagTreeNode NoteType = pb.NoteType DeckTreeNode = pb.DeckTreeNode StockNoteType = pb.StockNoteType -FilterToSearchIn = pb.FilterToSearchIn -NamedFilter = pb.FilterToSearchIn.NamedFilter -DupeIn = pb.FilterToSearchIn.DupeIn BackendNoteTypeID = pb.NoteTypeID ConcatSeparator = pb.ConcatenateSearchesIn.Separator SyncAuth = pb.SyncAuth diff --git a/pylib/anki/tags.py b/pylib/anki/tags.py index 5ced38aa2..c8e969568 100644 --- a/pylib/anki/tags.py +++ b/pylib/anki/tags.py @@ -16,7 +16,7 @@ import re from typing import Collection, List, Optional, Sequence, Tuple import anki # pylint: disable=unused-import -from anki.rsbackend import FilterToSearchIn +from anki.collection import SearchTerm from anki.utils import ids2str @@ -87,8 +87,7 @@ class TagManager: def rename_tag(self, old: str, new: str) -> int: "Rename provided tag, returning number of changed notes." - search = self.col.backend.filter_to_search(FilterToSearchIn(tag=old)) - nids = self.col.find_notes(search) + nids = self.col.find_notes(SearchTerm(tag=old)) if not nids: return 0 escaped_name = re.sub(r"[*_\\]", r"\\\g<0>", old) diff --git a/qt/.pylintrc b/qt/.pylintrc index 608b97226..33e90dfdc 100644 --- a/qt/.pylintrc +++ b/qt/.pylintrc @@ -5,6 +5,7 @@ ignore = forms,hooks_gen.py [TYPECHECK] ignored-modules=win32file,pywintypes,socket,win32pipe,winrt,pyaudio +ignored-classes=SearchTerm [REPORTS] output-format=colorized diff --git a/qt/aqt/addcards.py b/qt/aqt/addcards.py index 067290342..5a8d07607 100644 --- a/qt/aqt/addcards.py +++ b/qt/aqt/addcards.py @@ -7,6 +7,7 @@ import aqt.deckchooser import aqt.editor import aqt.forms import aqt.modelchooser +from anki.collection import SearchTerm from anki.consts import MODEL_CLOZE from anki.notes import Note from anki.utils import htmlToTextLine, isMac @@ -144,7 +145,7 @@ class AddCards(QDialog): def onHistory(self) -> None: m = QMenu(self) for nid in self.history: - if self.mw.col.findNotes("nid:%s" % nid): + if self.mw.col.findNotes(SearchTerm(nid=nid)): note = self.mw.col.getNote(nid) fields = note.fields txt = htmlToTextLine(", ".join(fields)) @@ -161,9 +162,7 @@ class AddCards(QDialog): m.exec_(self.historyButton.mapToGlobal(QPoint(0, 0))) def editHistory(self, nid): - browser = aqt.dialogs.open("Browser", self.mw) - browser.form.searchEdit.lineEdit().setText("nid:%d" % nid) - browser.onSearchActivated() + self.mw.browser_search(SearchTerm(nid=nid)) def addNote(self, note) -> Optional[Note]: note.model()["did"] = self.deckChooser.selectedId() diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 405caf72b..5b29e646b 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -13,19 +13,11 @@ from typing import List, Optional, Sequence, Tuple, cast import aqt import aqt.forms from anki.cards import Card -from anki.collection import Collection, ConfigBoolKey +from anki.collection import Collection, ConfigBoolKey, InvalidInput, SearchTerm from anki.consts import * from anki.lang import without_unicode_isolation from anki.models import NoteType from anki.notes import Note -from anki.rsbackend import ( - BackendNoteTypeID, - ConcatSeparator, - DupeIn, - FilterToSearchIn, - InvalidInput, - NamedFilter, -) from anki.stats import CardStats from anki.utils import htmlToTextLine, ids2str, isMac, isWin from aqt import AnkiQt, gui_hooks @@ -193,23 +185,18 @@ class DataModel(QAbstractTableModel): def search(self, txt: str) -> None: self.beginReset() self.cards = [] - exception: Optional[Exception] = None try: ctx = SearchContext(search=txt, browser=self.browser) gui_hooks.browser_will_search(ctx) if ctx.card_ids is None: - ctx.search = self.browser.normalize_search(ctx.search) ctx.card_ids = self.col.find_cards(ctx.search, order=ctx.order) gui_hooks.browser_did_search(ctx) self.cards = ctx.card_ids - except Exception as e: - exception = e + except Exception as err: + raise err finally: self.endReset() - if exception: - show_invalid_search_error(exception) - def reset(self): self.beginReset() self.endReset() @@ -615,16 +602,13 @@ class Browser(QMainWindow): ###################################################################### def setupSearch(self): - qconnect(self.form.searchButton.clicked, self.onSearchActivated) qconnect(self.form.searchEdit.lineEdit().returnPressed, self.onSearchActivated) self.form.searchEdit.setCompleter(None) - self._searchPrompt = tr(TR.BROWSING_TYPE_HERE_TO_SEARCH) - self.form.searchEdit.addItems( - [self._searchPrompt] + self.mw.pm.profile["searchHistory"] + self.form.searchEdit.lineEdit().setPlaceholderText( + tr(TR.BROWSING_SEARCH_BAR_HINT) ) - self.search_for("is:current", self._searchPrompt) - # then replace text for easily showing the deck - self.form.searchEdit.lineEdit().selectAll() + self.form.searchEdit.addItems(self.mw.pm.profile["searchHistory"]) + self.search_for(self.col.build_search_string(SearchTerm(current_deck=True)), "") self.form.searchEdit.setFocus() # search triggered by user @@ -632,57 +616,48 @@ class Browser(QMainWindow): self.editor.saveNow(self._onSearchActivated) def _onSearchActivated(self): - # grab search text and normalize - prompt = self.form.searchEdit.lineEdit().text() + text = self.form.searchEdit.lineEdit().text() + try: + normed = self.col.build_search_string(text) + except InvalidInput as err: + show_invalid_search_error(err) + else: + self.search_for(normed) + self.update_history() - # convert guide text before we save history - txt = "deck:current " if prompt == self._searchPrompt else prompt - self.update_history(txt) + def search_for(self, search: str, prompt: Optional[str] = None): + """Keep track of search string so that we reuse identical search when + refreshing, rather than whatever is currently in the search field. + Optionally set the search bar to a different text than the actual search. + """ - # keep track of search string so that we reuse identical search when - # refreshing, rather than whatever is currently in the search field - self.search_for(txt) + self._lastSearchTxt = search + prompt = search if prompt == None else prompt + self.form.searchEdit.lineEdit().setText(prompt) + self.search() - def update_history(self, search: str) -> None: + def search(self): + """Search triggered programmatically. Caller must have saved note first.""" + + try: + self.model.search(self._lastSearchTxt) + except Exception as err: + show_invalid_search_error(err) + if not self.model.cards: + # no row change will fire + self._onRowChanged(None, None) + + def update_history(self): sh = self.mw.pm.profile["searchHistory"] - if search in sh: - sh.remove(search) - sh.insert(0, search) + if self._lastSearchTxt in sh: + sh.remove(self._lastSearchTxt) + sh.insert(0, self._lastSearchTxt) sh = sh[:30] self.form.searchEdit.clear() self.form.searchEdit.addItems(sh) self.mw.pm.profile["searchHistory"] = sh - def search_for(self, search: str, prompt: Optional[str] = None) -> None: - self._lastSearchTxt = search - self.form.searchEdit.lineEdit().setText(prompt or search) - self.search() - - # search triggered programmatically. caller must have saved note first. - def search(self) -> None: - if "is:current" in self._lastSearchTxt: - # show current card if there is one - c = self.card = self.mw.reviewer.card - nid = c and c.nid or 0 - if nid: - search = "nid:%d" % nid - search = gui_hooks.default_search(search, c) - self.model.search(search) - self.focusCid(c.id) - else: - self.model.search(self._lastSearchTxt) - - if not self.model.cards: - # no row change will fire - self._onRowChanged(None, None) - - def normalize_search(self, search: str) -> str: - normed = self.col.backend.normalize_search(search) - self._lastSearchTxt = normed - self.form.searchEdit.lineEdit().setText(normed) - return normed - - def updateTitle(self): + def updateTitle(self) -> int: selected = len(self.form.tableView.selectionModel().selectedRows()) cur = len(self.model.cards) self.setWindowTitle( @@ -692,6 +667,21 @@ class Browser(QMainWindow): ) return selected + def show_single_card(self, card: Optional[Card]): + """Try to search for the according note and select the given card.""" + + nid: Optional[int] = card and card.nid or 0 + if nid: + + def on_show_single_card(): + self.card = card + search = self.col.build_search_string(SearchTerm(nid=nid)) + search = gui_hooks.default_search(search, card) + self.search_for(search, "") + self.focusCid(card.id) + + self.editor.saveNow(on_show_single_card) + def onReset(self): self.sidebar.refresh() self.editor.setNote(None) @@ -972,33 +962,20 @@ QTableView {{ gridline-color: {grid} }} ml.popupOver(self.form.filter) - def update_search(self, *terms: str): - "Modify the current search string based on modified keys, then refresh." + def update_search(self, *terms: Union[str, SearchTerm]): + """Modify the current search string based on modified keys, then refresh.""" try: - search = self.col.backend.concatenate_searches( - sep=ConcatSeparator.AND, searches=terms - ) + search = self.col.build_search_string(*terms) mods = self.mw.app.keyboardModifiers() if mods & Qt.AltModifier: - search = self.col.backend.negate_search(search) + search = self.col.build_search_string(search, negate=True) cur = str(self.form.searchEdit.lineEdit().text()) - if cur != self._searchPrompt: - if mods & Qt.ControlModifier and mods & Qt.ShiftModifier: - search = self.col.backend.replace_search_term( - search=cur, replacement=search - ) - elif mods & Qt.ControlModifier: - search = self.col.backend.concatenate_searches( - # pylint: disable=no-member - sep=ConcatSeparator.AND, - searches=[cur, search], - ) - elif mods & Qt.ShiftModifier: - search = self.col.backend.concatenate_searches( - # pylint: disable=no-member - sep=ConcatSeparator.OR, - searches=[cur, search], - ) + if mods & Qt.ControlModifier and mods & Qt.ShiftModifier: + search = self.col.replace_search_term(cur, search) + elif mods & Qt.ControlModifier: + search = self.col.build_search_string(cur, search) + elif mods & Qt.ShiftModifier: + search = self.col.build_search_string(cur, search, match_any=True) except InvalidInput as e: show_invalid_search_error(e) else: @@ -1016,7 +993,7 @@ QTableView {{ gridline-color: {grid} }} ml.addSeparator() else: label, filter_name = row - ml.addItem(label, self.sidebar._named_filter(filter_name)) + ml.addItem(label, self.sidebar._filter_func(filter_name)) return ml def _todayFilters(self): @@ -1024,9 +1001,19 @@ QTableView {{ gridline-color: {grid} }} subm.addChild( self._simpleFilters( ( - (tr(TR.BROWSING_ADDED_TODAY), NamedFilter.ADDED_TODAY), - (tr(TR.BROWSING_STUDIED_TODAY), NamedFilter.STUDIED_TODAY), - (tr(TR.BROWSING_AGAIN_TODAY), NamedFilter.AGAIN_TODAY), + (tr(TR.BROWSING_ADDED_TODAY), SearchTerm(added_in_days=1)), + ( + tr(TR.BROWSING_STUDIED_TODAY), + SearchTerm(rated=SearchTerm.Rated(days=1)), + ), + ( + tr(TR.BROWSING_AGAIN_TODAY), + SearchTerm( + rated=SearchTerm.Rated( + days=1, rating=SearchTerm.RATING_AGAIN + ) + ), + ), ) ) ) @@ -1037,20 +1024,41 @@ QTableView {{ gridline-color: {grid} }} subm.addChild( self._simpleFilters( ( - (tr(TR.ACTIONS_NEW), NamedFilter.NEW), - (tr(TR.SCHEDULING_LEARNING), NamedFilter.LEARN), - (tr(TR.SCHEDULING_REVIEW), NamedFilter.REVIEW), - (tr(TR.FILTERING_IS_DUE), NamedFilter.DUE), + ( + tr(TR.ACTIONS_NEW), + SearchTerm(card_state=SearchTerm.CARD_STATE_NEW), + ), + ( + tr(TR.SCHEDULING_LEARNING), + SearchTerm(card_state=SearchTerm.CARD_STATE_LEARN), + ), + ( + tr(TR.SCHEDULING_REVIEW), + SearchTerm(card_state=SearchTerm.CARD_STATE_REVIEW), + ), + ( + tr(TR.FILTERING_IS_DUE), + SearchTerm(card_state=SearchTerm.CARD_STATE_DUE), + ), None, - (tr(TR.BROWSING_SUSPENDED), NamedFilter.SUSPENDED), - (tr(TR.BROWSING_BURIED), NamedFilter.BURIED), + ( + tr(TR.BROWSING_SUSPENDED), + SearchTerm(card_state=SearchTerm.CARD_STATE_SUSPENDED), + ), + ( + tr(TR.BROWSING_BURIED), + SearchTerm(card_state=SearchTerm.CARD_STATE_BURIED), + ), None, - (tr(TR.ACTIONS_RED_FLAG), NamedFilter.RED_FLAG), - (tr(TR.ACTIONS_ORANGE_FLAG), NamedFilter.ORANGE_FLAG), - (tr(TR.ACTIONS_GREEN_FLAG), NamedFilter.GREEN_FLAG), - (tr(TR.ACTIONS_BLUE_FLAG), NamedFilter.BLUE_FLAG), - (tr(TR.BROWSING_NO_FLAG), NamedFilter.NO_FLAG), - (tr(TR.BROWSING_ANY_FLAG), NamedFilter.ANY_FLAG), + (tr(TR.ACTIONS_RED_FLAG), SearchTerm(flag=SearchTerm.FLAG_RED)), + ( + tr(TR.ACTIONS_ORANGE_FLAG), + SearchTerm(flag=SearchTerm.FLAG_ORANGE), + ), + (tr(TR.ACTIONS_GREEN_FLAG), SearchTerm(flag=SearchTerm.FLAG_GREEN)), + (tr(TR.ACTIONS_BLUE_FLAG), SearchTerm(flag=SearchTerm.FLAG_BLUE)), + (tr(TR.BROWSING_NO_FLAG), SearchTerm(flag=SearchTerm.FLAG_NONE)), + (tr(TR.BROWSING_ANY_FLAG), SearchTerm(flag=SearchTerm.FLAG_ANY)), ) ) ) @@ -1450,7 +1458,9 @@ where id in %s""" tv = self.form.tableView tv.selectionModel().clear() - search = "nid:" + ",".join([str(x) for x in nids]) + search = self.col.build_search_string( + SearchTerm(nids=SearchTerm.IdList(ids=nids)) + ) self.search_for(search) tv.selectAll() @@ -1593,17 +1603,6 @@ where id in %s""" # Edit: finding dupes ###################################################################### - # filter called by the editor - def search_dupe(self, mid: int, text: str): - self.form.searchEdit.lineEdit().setText( - self.col.backend.filter_to_search( - FilterToSearchIn( - dupe=DupeIn(mid=BackendNoteTypeID(ntid=mid), text=text) - ) - ) - ) - self.onSearchActivated() - def onFindDupes(self): self.editor.saveNow(self._onFindDupes) @@ -1648,7 +1647,12 @@ where id in %s""" def duplicatesReport(self, web, fname, search, frm, web_context): self.mw.progress.start() - res = self.mw.col.findDupes(fname, search) + try: + res = self.mw.col.findDupes(fname, search) + except InvalidInput as e: + self.mw.progress.finish() + show_invalid_search_error(e) + return if not self._dupesButton: self._dupesButton = b = frm.buttonBox.addButton( tr(TR.BROWSING_TAG_DUPLICATES), QDialogButtonBox.ActionRole @@ -1665,7 +1669,11 @@ where id in %s""" t += ( """
  • %s: %s""" % ( - "nid:" + ",".join(str(id) for id in nids), + html.escape( + self.col.build_search_string( + SearchTerm(nids=SearchTerm.IdList(ids=nids)) + ) + ), tr(TR.BROWSING_NOTE_COUNT, count=len(nids)), html.escape(val), ) @@ -1761,9 +1769,10 @@ where id in %s""" def focusCid(self, cid): try: - row = self.model.cards.index(cid) - except: + row = list(self.model.cards).index(cid) + except ValueError: return + self.form.tableView.clearSelection() self.form.tableView.selectRow(row) diff --git a/qt/aqt/customstudy.py b/qt/aqt/customstudy.py index 416f01bfe..baa022243 100644 --- a/qt/aqt/customstudy.py +++ b/qt/aqt/customstudy.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import aqt +from anki.collection import SearchTerm from anki.consts import * from aqt.qt import * from aqt.utils import TR, disable_help_button, showInfo, showWarning, tr @@ -159,26 +160,42 @@ class CustomStudy(QDialog): dyn = self.mw.col.decks.get(did) # and then set various options if i == RADIO_FORGOT: - dyn["terms"][0] = ["rated:%d:1" % spin, DYN_MAX_SIZE, DYN_RANDOM] + search = self.mw.col.build_search_string( + SearchTerm( + rated=SearchTerm.Rated(days=spin, rating=SearchTerm.RATING_AGAIN) + ) + ) + dyn["terms"][0] = [search, DYN_MAX_SIZE, DYN_RANDOM] dyn["resched"] = False elif i == RADIO_AHEAD: - dyn["terms"][0] = ["prop:due<=%d" % spin, DYN_MAX_SIZE, DYN_DUE] + search = self.mw.col.build_search_string(SearchTerm(due_in_days=spin)) + dyn["terms"][0] = [search, DYN_MAX_SIZE, DYN_DUE] dyn["resched"] = True elif i == RADIO_PREVIEW: - dyn["terms"][0] = ["is:new added:%s" % spin, DYN_MAX_SIZE, DYN_OLDEST] + search = self.mw.col.build_search_string( + SearchTerm(card_state=SearchTerm.CARD_STATE_NEW), + SearchTerm(added_in_days=spin), + ) + dyn["terms"][0] = [search, DYN_MAX_SIZE, DYN_OLDEST] dyn["resched"] = False elif i == RADIO_CRAM: type = f.cardType.currentRow() if type == TYPE_NEW: - terms = "is:new " + terms = self.mw.col.build_search_string( + SearchTerm(card_state=SearchTerm.CARD_STATE_NEW) + ) ord = DYN_ADDED dyn["resched"] = True elif type == TYPE_DUE: - terms = "is:due " + terms = self.mw.col.build_search_string( + SearchTerm(card_state=SearchTerm.CARD_STATE_DUE) + ) ord = DYN_DUE dyn["resched"] = True elif type == TYPE_REVIEW: - terms = "-is:new " + terms = self.mw.col.build_search_string( + SearchTerm(card_state=SearchTerm.CARD_STATE_NEW), negate=True + ) ord = DYN_RANDOM dyn["resched"] = True else: @@ -187,7 +204,9 @@ class CustomStudy(QDialog): dyn["resched"] = False dyn["terms"][0] = [(terms + tags).strip(), spin, ord] # add deck limit - dyn["terms"][0][0] = 'deck:"%s" %s ' % (self.deck["name"], dyn["terms"][0][0]) + dyn["terms"][0][0] = self.mw.col.build_search_string( + dyn["terms"][0][0], SearchTerm(deck=self.deck["name"]) + ) self.mw.col.decks.save(dyn) # generate cards self.created_custom_study = True diff --git a/qt/aqt/dyndeckconf.py b/qt/aqt/dyndeckconf.py index 6c47528a1..c9d0d6338 100644 --- a/qt/aqt/dyndeckconf.py +++ b/qt/aqt/dyndeckconf.py @@ -4,8 +4,8 @@ from typing import List, Optional import aqt +from anki.collection import InvalidInput, SearchTerm from anki.lang import without_unicode_isolation -from anki.rsbackend import InvalidInput from aqt.qt import * from aqt.utils import ( TR, @@ -47,8 +47,14 @@ class DeckConf(QDialog): self.initialSetup() self.loadConf() if search: - self.form.search.setText(search + " is:due") - self.form.search_2.setText(search + " is:new") + search = self.mw.col.build_search_string( + search, SearchTerm(card_state=SearchTerm.CARD_STATE_DUE) + ) + self.form.search.setText(search) + search_2 = self.mw.col.build_search_string( + search, SearchTerm(card_state=SearchTerm.CARD_STATE_NEW) + ) + self.form.search_2.setText(search_2) self.form.search.selectAll() if self.mw.col.schedVer() == 1: @@ -119,11 +125,11 @@ class DeckConf(QDialog): else: d["delays"] = None - search = self.mw.col.backend.normalize_search(f.search.text()) + search = self.mw.col.build_search_string(f.search.text()) terms = [[search, f.limit.value(), f.order.currentIndex()]] if f.secondFilter.isChecked(): - search_2 = self.mw.col.backend.normalize_search(f.search_2.text()) + search_2 = self.mw.col.build_search_string(f.search_2.text()) terms.append([search_2, f.limit_2.value(), f.order_2.currentIndex()]) d["terms"] = terms diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index 4f7d980ad..f609089c9 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -21,6 +21,7 @@ from bs4 import BeautifulSoup import aqt import aqt.sound from anki.cards import Card +from anki.collection import SearchTerm from anki.consts import MODEL_CLOZE from anki.hooks import runFilter from anki.httpclient import HttpClient @@ -537,8 +538,13 @@ class Editor: self.web.eval("setBackgrounds(%s);" % json.dumps(cols)) def showDupes(self): - browser = aqt.dialogs.open("Browser", self.mw) - browser.search_dupe(self.note.model()["id"], self.note.fields[0]) + self.mw.browser_search( + SearchTerm( + dupe=SearchTerm.Dupe( + notetype_id=self.note.model()["id"], first_field=self.note.fields[0] + ) + ) + ) def fieldsAreBlank(self, previousNote=None): if not self.note: diff --git a/qt/aqt/emptycards.py b/qt/aqt/emptycards.py index d7d0d0801..e399ce1da 100644 --- a/qt/aqt/emptycards.py +++ b/qt/aqt/emptycards.py @@ -66,9 +66,7 @@ class EmptyCardsDialog(QDialog): self._delete_button.clicked.connect(self._on_delete) def _on_note_link_clicked(self, link): - browser = aqt.dialogs.open("Browser", self.mw) - browser.form.searchEdit.lineEdit().setText(link) - browser.onSearchActivated() + self.mw.browser_search(link) def _on_delete(self): self.mw.progress.start() diff --git a/qt/aqt/forms/browser.ui b/qt/aqt/forms/browser.ui index 87335617a..ba6a48980 100644 --- a/qt/aqt/forms/browser.ui +++ b/qt/aqt/forms/browser.ui @@ -107,13 +107,6 @@ - - - - ACTIONS_SEARCH - - - @@ -158,12 +151,12 @@ false - - 20 - false + + 20 + true @@ -223,7 +216,7 @@ 0 0 750 - 22 + 21 diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 8812a4cde..2653e11de 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -26,7 +26,7 @@ import aqt.stats import aqt.toolbar import aqt.webview from anki import hooks -from anki.collection import Collection +from anki.collection import Collection, SearchTerm from anki.decks import Deck from anki.hooks import runHook from anki.lang import without_unicode_isolation @@ -1045,7 +1045,8 @@ title="%s" %s>%s""" % ( aqt.dialogs.open("AddCards", self) def onBrowse(self) -> None: - aqt.dialogs.open("Browser", self) + browser = aqt.dialogs.open("Browser", self) + browser.show_single_card(self.reviewer.card) def onEditCurrent(self): aqt.dialogs.open("EditCurrent", self) @@ -1141,7 +1142,7 @@ title="%s" %s>%s""" % ( deck = self.col.decks.current() if not search: if not deck["dyn"]: - search = 'deck:"%s" ' % deck["name"] + search = self.col.build_search_string(SearchTerm(deck=deck["name"])) while self.col.decks.id_for_name( without_unicode_isolation(tr(TR.QT_MISC_FILTERED_DECK, val=n)) ): @@ -1617,3 +1618,14 @@ title="%s" %s>%s""" % ( def serverURL(self) -> str: return "http://127.0.0.1:%d/" % self.mediaServer.getPort() + + # Helpers for all windows + ########################################################################## + + def browser_search(self, *terms: Union[str, SearchTerm]) -> None: + """Wrapper for col.build_search_string() to look up the result in the browser.""" + + search = self.col.build_search_string(*terms) + browser = aqt.dialogs.open("Browser", self) + browser.form.searchEdit.lineEdit().setText(search) + browser.onSearchActivated() diff --git a/qt/aqt/mediacheck.py b/qt/aqt/mediacheck.py index 092689a8d..50bf66f0e 100644 --- a/qt/aqt/mediacheck.py +++ b/qt/aqt/mediacheck.py @@ -9,6 +9,7 @@ from concurrent.futures import Future from typing import Iterable, List, Optional, Sequence, TypeVar import aqt +from anki.collection import SearchTerm from anki.rsbackend import TR, Interrupted, ProgressKind, pb from aqt.qt import * from aqt.utils import ( @@ -145,9 +146,7 @@ class MediaChecker: if out is not None: nid, err = out - browser = aqt.dialogs.open("Browser", self.mw) - browser.form.searchEdit.lineEdit().setText("nid:%d" % nid) - browser.onSearchActivated() + self.mw.browser_search(SearchTerm(nid=nid)) showText(err, type="html") else: tooltip(tr(TR.MEDIA_CHECK_ALL_LATEX_RENDERED)) diff --git a/qt/aqt/overview.py b/qt/aqt/overview.py index 52c3ad52b..088065c95 100644 --- a/qt/aqt/overview.py +++ b/qt/aqt/overview.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from typing import Optional import aqt +from anki.collection import SearchTerm from aqt import gui_hooks from aqt.sound import av_player from aqt.toolbar import BottomBar @@ -71,8 +72,8 @@ class Overview: elif url == "opts": self.mw.onDeckConf() elif url == "cram": - deck = self.mw.col.decks.current() - self.mw.onCram("'deck:%s'" % deck["name"]) + deck = self.mw.col.decks.current()["name"] + self.mw.onCram(self.mw.col.build_search_string(SearchTerm(deck=deck))) elif url == "refresh": self.mw.col.sched.rebuild_filtered_deck(self.mw.col.decks.selected()) self.mw.reset() diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index 78eac879a..744814186 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -9,15 +9,9 @@ from enum import Enum from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Sequence, Tuple, cast import aqt -from anki.collection import ConfigBoolKey +from anki.collection import ConfigBoolKey, InvalidInput, SearchTerm from anki.errors import DeckRenameError -from anki.rsbackend import ( - DeckTreeNode, - FilterToSearchIn, - InvalidInput, - NamedFilter, - TagTreeNode, -) +from anki.rsbackend import DeckTreeNode, TagTreeNode from aqt import gui_hooks from aqt.main import ResetReason from aqt.models import Models @@ -42,6 +36,7 @@ class SidebarItemType(Enum): COLLECTION = 1 CURRENT_DECK = 2 SAVED_SEARCH = 3 + FILTER = 3 # legacy alias for SAVED_SEARCH DECK = 4 NOTETYPE = 5 TAG = 6 @@ -466,14 +461,12 @@ class SidebarTreeView(QTreeView): item = SidebarItem( tr(TR.BROWSING_WHOLE_COLLECTION), ":/icons/collection.svg", - self._named_filter(NamedFilter.WHOLE_COLLECTION), item_type=SidebarItemType.COLLECTION, ) root.addChild(item) item = SidebarItem( tr(TR.BROWSING_CURRENT_DECK), ":/icons/deck.svg", - self._named_filter(NamedFilter.CURRENT_DECK), item_type=SidebarItemType.CURRENT_DECK, ) root.addChild(item) @@ -499,8 +492,8 @@ class SidebarTreeView(QTreeView): item = SidebarItem( name, icon, - self._saved_filter(filt), - item_type=SidebarItemType.SAVED_SEARCH, + self._filter_func(filt), + item_type=SidebarItemType.FILTER, ) root.addChild(item) @@ -519,7 +512,7 @@ class SidebarTreeView(QTreeView): item = SidebarItem( node.name, icon, - self._tag_filter(head + node.name), + self._filter_func(SearchTerm(tag=head + node.name)), toggle_expand(), not node.collapsed, item_type=SidebarItemType.TAG, @@ -551,7 +544,7 @@ class SidebarTreeView(QTreeView): item = SidebarItem( node.name, icon, - self._deck_filter(head + node.name), + self._filter_func(SearchTerm(deck=head + node.name)), toggle_expand(), not node.collapsed, item_type=SidebarItemType.DECK, @@ -584,8 +577,8 @@ class SidebarTreeView(QTreeView): for nt in sorted(self.col.models.all(), key=lambda nt: nt["name"].lower()): item = SidebarItem( nt["name"], - icon=icon, - onClick=self._note_filter(nt["name"]), + icon, + self._filter_func(SearchTerm(note=nt["name"])), item_type=SidebarItemType.NOTETYPE, id=nt["id"], ) @@ -594,7 +587,9 @@ class SidebarTreeView(QTreeView): child = SidebarItem( tmpl["name"], icon, - onClick=self._template_filter(nt["name"], c), + self._filter_func( + SearchTerm(note=nt["name"]), SearchTerm(template=c) + ), item_type=SidebarItemType.TEMPLATE, full_name=nt["name"] + "::" + tmpl["name"], ) @@ -602,34 +597,8 @@ class SidebarTreeView(QTreeView): root.addChild(item) - def _named_filter(self, name: "FilterToSearchIn.NamedFilterValue") -> Callable: - return lambda: self.browser.update_search( - self.col.backend.filter_to_search(FilterToSearchIn(name=name)) - ) - - def _tag_filter(self, tag: str) -> Callable: - return lambda: self.browser.update_search( - self.col.backend.filter_to_search(FilterToSearchIn(tag=tag)) - ) - - def _deck_filter(self, deck: str) -> Callable: - return lambda: self.browser.update_search( - self.col.backend.filter_to_search(FilterToSearchIn(deck=deck)) - ) - - def _note_filter(self, note: str) -> Callable: - return lambda: self.browser.update_search( - self.col.backend.filter_to_search(FilterToSearchIn(note=note)) - ) - - def _template_filter(self, note: str, template: int) -> Callable: - return lambda: self.browser.update_search( - self.col.backend.filter_to_search(FilterToSearchIn(note=note)), - self.col.backend.filter_to_search(FilterToSearchIn(template=template)), - ) - - def _saved_filter(self, saved: str) -> Callable: - return lambda: self.browser.update_search(saved) + def _filter_func(self, *terms: Union[str, SearchTerm]) -> Callable: + return lambda: self.browser.update_search(self.col.build_search_string(*terms)) # Context menu actions ########################### @@ -807,7 +776,7 @@ class SidebarTreeView(QTreeView): def save_current_search(self, _item=None) -> None: try: - filt = self.col.backend.normalize_search( + filt = self.col.build_search_string( self.browser.form.searchEdit.lineEdit().text() ) except InvalidInput as e: diff --git a/rslib/backend.proto b/rslib/backend.proto index 1488f1f61..e46026ee7 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -84,7 +84,7 @@ service BackendService { // searching - rpc FilterToSearch(FilterToSearchIn) returns (String); + rpc FilterToSearch(SearchTerm) returns (String); rpc NormalizeSearch(String) returns (String); rpc SearchCards(SearchCardsIn) returns (SearchCardsOut); rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut); @@ -765,37 +765,58 @@ message BuiltinSearchOrder { bool reverse = 2; } -message FilterToSearchIn { - enum NamedFilter { - WHOLE_COLLECTION = 0; - CURRENT_DECK = 1; - ADDED_TODAY = 2; - STUDIED_TODAY = 3; - AGAIN_TODAY = 4; - NEW = 5; - LEARN = 6; - REVIEW = 7; - DUE = 8; - SUSPENDED = 9; - BURIED = 10; - RED_FLAG = 11; - ORANGE_FLAG = 12; - GREEN_FLAG = 13; - BLUE_FLAG = 14; - NO_FLAG = 15; - ANY_FLAG = 16; +message SearchTerm { + message Dupe { + int64 notetype_id = 1; + string first_field = 2; } - message DupeIn { - NoteTypeID mid = 1; - string text = 2; + enum Flag { + FLAG_NONE = 0; + FLAG_ANY = 1; + FLAG_RED = 2; + FLAG_ORANGE = 3; + FLAG_GREEN = 4; + FLAG_BLUE = 5; + } + enum Rating { + RATING_ANY = 0; + RATING_AGAIN = 1; + RATING_HARD = 2; + RATING_GOOD = 3; + RATING_EASY = 4; + RATING_BY_RESCHEDULE = 5; + } + message Rated { + uint32 days = 1; + Rating rating = 2; + } + enum CardState { + CARD_STATE_NEW = 0; + CARD_STATE_LEARN = 1; + CARD_STATE_REVIEW = 2; + CARD_STATE_DUE = 3; + CARD_STATE_SUSPENDED = 4; + CARD_STATE_BURIED = 5; + } + message IdList { + repeated int64 ids = 1; } oneof filter { - NamedFilter name = 1; - string tag = 2; - string deck = 3; - string note = 4; - uint32 template = 5; - DupeIn dupe = 6; + string tag = 1; + string deck = 2; + string note = 3; + uint32 template = 4; + int64 nid = 5; + Dupe dupe = 6; + string field_name = 7; + Rated rated = 8; + uint32 added_in_days = 9; + int32 due_in_days = 10; + bool whole_collection = 11; + bool current_deck = 12; + Flag flag = 13; + CardState card_state = 14; + IdList nids = 15; } } diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index aeba5294e..614d2ade2 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -36,7 +36,8 @@ use crate::{ sched::timespan::{answer_button_time, time_span}, search::{ concatenate_searches, negate_search, normalize_search, replace_search_term, write_nodes, - BoolSeparator, EaseKind, Node, SearchNode, SortMode, StateKind, TemplateKind, + BoolSeparator, Node, PropertyKind, RatingKind, SearchNode, SortMode, StateKind, + TemplateKind, }, stats::studied_today, sync::{ @@ -262,6 +263,16 @@ impl From for NoteID { } } +impl pb::search_term::IdList { + fn into_id_string(self) -> String { + self.ids + .iter() + .map(|i| i.to_string()) + .collect::>() + .join(",") + } +} + impl From for NoteTypeID { fn from(ntid: pb::NoteTypeId) -> Self { NoteTypeID(ntid.ntid) @@ -280,41 +291,11 @@ impl From for DeckConfID { } } -impl From for Node<'_> { - fn from(msg: pb::FilterToSearchIn) -> Self { - use pb::filter_to_search_in::Filter; - use pb::filter_to_search_in::NamedFilter; - match msg - .filter - .unwrap_or(Filter::Name(NamedFilter::WholeCollection as i32)) - { - Filter::Name(name) => { - match NamedFilter::from_i32(name).unwrap_or(NamedFilter::WholeCollection) { - NamedFilter::WholeCollection => Node::Search(SearchNode::WholeCollection), - NamedFilter::CurrentDeck => Node::Search(SearchNode::Deck("current".into())), - NamedFilter::AddedToday => Node::Search(SearchNode::AddedInDays(1)), - NamedFilter::StudiedToday => Node::Search(SearchNode::Rated { - days: 1, - ease: EaseKind::AnyAnswerButton, - }), - NamedFilter::AgainToday => Node::Search(SearchNode::Rated { - days: 1, - ease: EaseKind::AnswerButton(1), - }), - NamedFilter::New => Node::Search(SearchNode::State(StateKind::New)), - NamedFilter::Learn => Node::Search(SearchNode::State(StateKind::Learning)), - NamedFilter::Review => Node::Search(SearchNode::State(StateKind::Review)), - NamedFilter::Due => Node::Search(SearchNode::State(StateKind::Due)), - NamedFilter::Suspended => Node::Search(SearchNode::State(StateKind::Suspended)), - NamedFilter::Buried => Node::Search(SearchNode::State(StateKind::Buried)), - NamedFilter::RedFlag => Node::Search(SearchNode::Flag(1)), - NamedFilter::OrangeFlag => Node::Search(SearchNode::Flag(2)), - NamedFilter::GreenFlag => Node::Search(SearchNode::Flag(3)), - NamedFilter::BlueFlag => Node::Search(SearchNode::Flag(4)), - NamedFilter::NoFlag => Node::Search(SearchNode::Flag(0)), - NamedFilter::AnyFlag => Node::Not(Box::new(Node::Search(SearchNode::Flag(0)))), - } - } +impl From for Node<'_> { + fn from(msg: pb::SearchTerm) -> Self { + use pb::search_term::Filter; + use pb::search_term::Flag; + match msg.filter.unwrap_or(Filter::WholeCollection(true)) { Filter::Tag(s) => Node::Search(SearchNode::Tag( escape_anki_wildcards(&s).into_owned().into(), )), @@ -327,10 +308,41 @@ impl From for Node<'_> { Filter::Template(u) => { Node::Search(SearchNode::CardTemplate(TemplateKind::Ordinal(u as u16))) } + Filter::Nid(nid) => Node::Search(SearchNode::NoteIDs(nid.to_string().into())), + Filter::Nids(nids) => Node::Search(SearchNode::NoteIDs(nids.into_id_string().into())), Filter::Dupe(dupe) => Node::Search(SearchNode::Duplicates { - note_type_id: dupe.mid.unwrap_or(pb::NoteTypeId { ntid: 0 }).into(), - text: dupe.text.into(), + note_type_id: dupe.notetype_id.into(), + text: dupe.first_field.into(), }), + Filter::FieldName(s) => Node::Search(SearchNode::SingleField { + field: escape_anki_wildcards(&s).into_owned().into(), + text: "*".to_string().into(), + is_re: false, + }), + Filter::Rated(rated) => Node::Search(SearchNode::Rated { + days: rated.days, + ease: rated.rating().into(), + }), + Filter::AddedInDays(u) => Node::Search(SearchNode::AddedInDays(u)), + Filter::DueInDays(i) => Node::Search(SearchNode::Property { + operator: "<=".to_string(), + kind: PropertyKind::Due(i), + }), + Filter::WholeCollection(_) => Node::Search(SearchNode::WholeCollection), + Filter::CurrentDeck(_) => Node::Search(SearchNode::Deck("current".into())), + Filter::CardState(state) => Node::Search(SearchNode::State( + pb::search_term::CardState::from_i32(state) + .unwrap_or_default() + .into(), + )), + Filter::Flag(flag) => match Flag::from_i32(flag).unwrap_or(Flag::Any) { + Flag::None => Node::Search(SearchNode::Flag(0)), + Flag::Any => Node::Not(Box::new(Node::Search(SearchNode::Flag(0)))), + Flag::Red => Node::Search(SearchNode::Flag(1)), + Flag::Orange => Node::Search(SearchNode::Flag(2)), + Flag::Green => Node::Search(SearchNode::Flag(3)), + Flag::Blue => Node::Search(SearchNode::Flag(4)), + }, } } } @@ -344,6 +356,32 @@ impl From for BoolSeparator { } } +impl From for RatingKind { + fn from(r: pb::search_term::Rating) -> Self { + match r { + pb::search_term::Rating::Again => RatingKind::AnswerButton(1), + pb::search_term::Rating::Hard => RatingKind::AnswerButton(2), + pb::search_term::Rating::Good => RatingKind::AnswerButton(3), + pb::search_term::Rating::Easy => RatingKind::AnswerButton(4), + pb::search_term::Rating::Any => RatingKind::AnyAnswerButton, + pb::search_term::Rating::ByReschedule => RatingKind::ManualReschedule, + } + } +} + +impl From for StateKind { + fn from(k: pb::search_term::CardState) -> Self { + match k { + pb::search_term::CardState::New => StateKind::New, + pb::search_term::CardState::Learn => StateKind::Learning, + pb::search_term::CardState::Review => StateKind::Review, + pb::search_term::CardState::Due => StateKind::Due, + pb::search_term::CardState::Suspended => StateKind::Suspended, + pb::search_term::CardState::Buried => StateKind::Buried, + } + } +} + impl BackendService for Backend { fn latest_progress(&self, _input: Empty) -> BackendResult { let progress = self.progress_state.lock().unwrap().last_progress; @@ -466,7 +504,7 @@ impl BackendService for Backend { // searching //----------------------------------------------- - fn filter_to_search(&self, input: pb::FilterToSearchIn) -> Result { + fn filter_to_search(&self, input: pb::SearchTerm) -> Result { Ok(write_nodes(&[input.into()]).into()) } diff --git a/rslib/src/search/mod.rs b/rslib/src/search/mod.rs index ef107a94a..ba396242c 100644 --- a/rslib/src/search/mod.rs +++ b/rslib/src/search/mod.rs @@ -5,7 +5,7 @@ mod sqlwriter; mod writer; pub use cards::SortMode; -pub use parser::{EaseKind, Node, PropertyKind, SearchNode, StateKind, TemplateKind}; +pub use parser::{Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind}; pub use writer::{ concatenate_searches, negate_search, normalize_search, replace_search_term, write_nodes, BoolSeparator, diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index e9b6a377b..ee7e2909f 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -58,7 +58,7 @@ pub enum SearchNode<'a> { NoteType(Cow<'a, str>), Rated { days: u32, - ease: EaseKind, + ease: RatingKind, }, Tag(Cow<'a, str>), Duplicates { @@ -67,7 +67,7 @@ pub enum SearchNode<'a> { }, State(StateKind), Flag(u8), - NoteIDs(&'a str), + NoteIDs(Cow<'a, str>), CardIDs(&'a str), Property { operator: String, @@ -87,7 +87,7 @@ pub enum PropertyKind { Lapses(u32), Ease(f32), Position(u32), - Rated(i32, EaseKind), + Rated(i32, RatingKind), } #[derive(Debug, PartialEq, Clone)] @@ -109,7 +109,7 @@ pub enum TemplateKind<'a> { } #[derive(Debug, PartialEq, Clone)] -pub enum EaseKind { +pub enum RatingKind { AnswerButton(u8), AnyAnswerButton, ManualReschedule, @@ -318,7 +318,7 @@ fn search_node_for_text_with_argument<'a>( "is" => parse_state(val)?, "did" => parse_did(val)?, "mid" => parse_mid(val)?, - "nid" => SearchNode::NoteIDs(check_id_list(val, key)?), + "nid" => SearchNode::NoteIDs(check_id_list(val, key)?.into()), "cid" => SearchNode::CardIDs(check_id_list(val, key)?), "re" => SearchNode::Regex(unescape_quotes(val)), "nc" => SearchNode::NoCombining(unescape(val)?), @@ -353,7 +353,7 @@ fn parse_flag(s: &str) -> ParseResult { fn parse_resched(s: &str) -> ParseResult { parse_u32(s, "resched:").map(|days| SearchNode::Rated { days, - ease: EaseKind::ManualReschedule, + ease: RatingKind::ManualReschedule, }) } @@ -392,7 +392,7 @@ fn parse_prop(prop_clause: &str) -> ParseResult { "rated" => parse_prop_rated(num, prop_clause)?, "resched" => PropertyKind::Rated( parse_negative_i32(num, prop_clause)?, - EaseKind::ManualReschedule, + RatingKind::ManualReschedule, ), "ivl" => PropertyKind::Interval(parse_u32(num, prop_clause)?), "reps" => PropertyKind::Reps(parse_u32(num, prop_clause)?), @@ -470,9 +470,9 @@ fn parse_i64<'a>(num: &str, context: &'a str) -> ParseResult<'a, i64> { }) } -fn parse_answer_button<'a>(num: Option<&str>, context: &'a str) -> ParseResult<'a, EaseKind> { +fn parse_answer_button<'a>(num: Option<&str>, context: &'a str) -> ParseResult<'a, RatingKind> { Ok(if let Some(num) = num { - EaseKind::AnswerButton( + RatingKind::AnswerButton( num.parse() .map_err(|_| ()) .and_then(|n| if matches!(n, 1..=4) { Ok(n) } else { Err(()) }) @@ -487,7 +487,7 @@ fn parse_answer_button<'a>(num: Option<&str>, context: &'a str) -> ParseResult<' })?, ) } else { - EaseKind::AnyAnswerButton + RatingKind::AnyAnswerButton }) } @@ -813,7 +813,7 @@ mod test { assert_eq!(parse("tag:hard")?, vec![Search(Tag("hard".into()))]); assert_eq!( parse("nid:1237123712,2,3")?, - vec![Search(NoteIDs("1237123712,2,3"))] + vec![Search(NoteIDs("1237123712,2,3".into()))] ); assert_eq!(parse("is:due")?, vec![Search(State(StateKind::Due))]); assert_eq!(parse("flag:3")?, vec![Search(Flag(3))]); diff --git a/rslib/src/search/sqlwriter.rs b/rslib/src/search/sqlwriter.rs index 6921794c6..a3e009d5e 100644 --- a/rslib/src/search/sqlwriter.rs +++ b/rslib/src/search/sqlwriter.rs @@ -1,7 +1,7 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use super::parser::{EaseKind, Node, PropertyKind, SearchNode, StateKind, TemplateKind}; +use super::parser::{Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind}; use crate::{ card::{CardQueue, CardType}, collection::Collection, @@ -211,7 +211,7 @@ impl SqlWriter<'_> { Ok(()) } - fn write_rated(&mut self, op: &str, days: i64, ease: &EaseKind) -> Result<()> { + fn write_rated(&mut self, op: &str, days: i64, ease: &RatingKind) -> Result<()> { let today_cutoff = self.col.timing_today()?.next_day_at; let target_cutoff_ms = (today_cutoff + 86_400 * days) * 1_000; let day_before_cutoff_ms = (today_cutoff + 86_400 * (days - 1)) * 1_000; @@ -240,9 +240,9 @@ impl SqlWriter<'_> { .unwrap(); match ease { - EaseKind::AnswerButton(u) => write!(self.sql, " and ease = {})", u), - EaseKind::AnyAnswerButton => write!(self.sql, " and ease > 0)"), - EaseKind::ManualReschedule => write!(self.sql, " and ease = 0)"), + RatingKind::AnswerButton(u) => write!(self.sql, " and ease = {})", u), + RatingKind::AnyAnswerButton => write!(self.sql, " and ease > 0)"), + RatingKind::ManualReschedule => write!(self.sql, " and ease = 0)"), } .unwrap(); diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs index 1715c11e3..ac4490e9e 100644 --- a/rslib/src/search/writer.rs +++ b/rslib/src/search/writer.rs @@ -5,7 +5,7 @@ use crate::{ decks::DeckID as DeckIDType, err::Result, notetype::NoteTypeID as NoteTypeIDType, - search::parser::{parse, EaseKind, Node, PropertyKind, SearchNode, StateKind, TemplateKind}, + search::parser::{parse, Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind}, }; use itertools::Itertools; use std::mem; @@ -154,8 +154,8 @@ fn write_template(template: &TemplateKind) -> String { } } -fn write_rated(days: &u32, ease: &EaseKind) -> String { - use EaseKind::*; +fn write_rated(days: &u32, ease: &RatingKind) -> String { + use RatingKind::*; match ease { AnswerButton(n) => format!("\"rated:{}:{}\"", days, n), AnyAnswerButton => format!("\"rated:{}\"", days), @@ -196,9 +196,9 @@ fn write_property(operator: &str, kind: &PropertyKind) -> String { Ease(f) => format!("\"prop:ease{}{}\"", operator, f), Position(u) => format!("\"prop:pos{}{}\"", operator, u), Rated(u, ease) => match ease { - EaseKind::AnswerButton(val) => format!("\"prop:rated{}{}:{}\"", operator, u, val), - EaseKind::AnyAnswerButton => format!("\"prop:rated{}{}\"", operator, u), - EaseKind::ManualReschedule => format!("\"prop:resched{}{}\"", operator, u), + RatingKind::AnswerButton(val) => format!("\"prop:rated{}{}:{}\"", operator, u, val), + RatingKind::AnyAnswerButton => format!("\"prop:rated{}{}\"", operator, u), + RatingKind::ManualReschedule => format!("\"prop:resched{}{}\"", operator, u), }, } }