diff --git a/proto/anki/backend.proto b/proto/anki/backend.proto index 6613737fb..f9e9636ef 100644 --- a/proto/anki/backend.proto +++ b/proto/anki/backend.proto @@ -55,6 +55,7 @@ message BackendError { EXISTS = 12; FILTERED_DECK_ERROR = 13; SEARCH_ERROR = 14; + CUSTOM_STUDY_ERROR = 15; } // localized error description suitable for displaying to the user diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto index 7876e3554..58d016c86 100644 --- a/proto/anki/scheduler.proto +++ b/proto/anki/scheduler.proto @@ -37,6 +37,7 @@ service SchedulerService { rpc DescribeNextStates(NextCardStates) returns (generic.StringList); rpc StateIsLeech(SchedulingState) returns (generic.Bool); rpc UpgradeScheduler(generic.Empty) returns (generic.Empty); + rpc CustomStudy(CustomStudyRequest) returns (collection.OpChanges); } message SchedulingState { @@ -217,3 +218,38 @@ message CardAnswer { int64 answered_at_millis = 5; uint32 milliseconds_taken = 6; } + +message CustomStudyRequest { + message Cram { + enum CramKind { + // due cards in due order + CRAM_KIND_DUE = 0; + // new cards in added order + CRAM_KIND_NEW = 1; + // review cards in random order + CRAM_KIND_REVIEW = 2; + // all cards in random order; no rescheduling + CRAM_KIND_ALL = 3; + } + CramKind kind = 1; + // the maximimum number of cards + uint32 card_limit = 2; + // cards must match one of these, if unempty + repeated string tags_to_include = 3; + // cards must not match any of these + repeated string tags_to_exclude = 4; + } + oneof value { + // increase new limit by x + int32 new_limit_delta = 1; + // increase review limit by x + int32 review_limit_delta = 2; + // repeat cards forgotten in the last x days + uint32 forgot_days = 3; + // review cards due in the next x days + uint32 review_ahead_days = 4; + // preview new cards added in the last x days + uint32 preview_days = 5; + Cram cram = 6; + } +} diff --git a/pylib/.pylintrc b/pylib/.pylintrc index 00f2ef605..9901aa66c 100644 --- a/pylib/.pylintrc +++ b/pylib/.pylintrc @@ -18,7 +18,9 @@ ignored-classes= SetDeckCollapsedRequest, ConfigKey, HelpPageLinkRequest, - StripHtmlRequest + StripHtmlRequest, + CustomStudyRequest, + Cram, [REPORTS] output-format=colorized diff --git a/pylib/anki/_backend/__init__.py b/pylib/anki/_backend/__init__.py index 49c6af7c6..e01a6bd99 100644 --- a/pylib/anki/_backend/__init__.py +++ b/pylib/anki/_backend/__init__.py @@ -20,6 +20,7 @@ from anki.utils import from_json_bytes, to_json_bytes from ..errors import ( BackendIOError, + CustomStudyError, DBError, ExistsError, FilteredDeckError, @@ -218,6 +219,9 @@ def backend_exception_to_pylib(err: backend_pb2.BackendError) -> Exception: elif val == kind.UNDO_EMPTY: return UndoEmpty() + elif val == kind.CUSTOM_STUDY_ERROR: + return CustomStudyError(err.localized) + else: # sadly we can't do exhaustiveness checking on protobuf enums # assert_exhaustive(val) diff --git a/pylib/anki/errors.py b/pylib/anki/errors.py index 1514916d4..07e26cc9e 100644 --- a/pylib/anki/errors.py +++ b/pylib/anki/errors.py @@ -40,6 +40,10 @@ class BackendIOError(LocalizedError): pass +class CustomStudyError(LocalizedError): + pass + + class DBError(LocalizedError): pass diff --git a/pylib/anki/scheduler/__init__.py b/pylib/anki/scheduler/__init__.py index 46ad82b02..5965d2e05 100644 --- a/pylib/anki/scheduler/__init__.py +++ b/pylib/anki/scheduler/__init__.py @@ -11,6 +11,7 @@ UnburyDeck = _base.UnburyDeck CongratsInfo = _base.CongratsInfo BuryOrSuspend = _base.BuryOrSuspend FilteredDeckForUpdate = _base.FilteredDeckForUpdate +CustomStudyRequest = _base.CustomStudyRequest # add aliases to the legacy pathnames import anki.scheduler.v1 diff --git a/pylib/anki/scheduler/base.py b/pylib/anki/scheduler/base.py index 729b9e312..bc90edb88 100644 --- a/pylib/anki/scheduler/base.py +++ b/pylib/anki/scheduler/base.py @@ -13,6 +13,7 @@ SchedTimingToday = scheduler_pb2.SchedTimingTodayResponse CongratsInfo = scheduler_pb2.CongratsInfoResponse UnburyDeck = scheduler_pb2.UnburyDeckRequest BuryOrSuspend = scheduler_pb2.BuryOrSuspendCardsRequest +CustomStudyRequest = scheduler_pb2.CustomStudyRequest FilteredDeckForUpdate = decks_pb2.FilteredDeckForUpdate @@ -68,6 +69,9 @@ class SchedulerBase(DeprecatedNamesMixin): info = self.congratulations_info() return info.have_sched_buried or info.have_user_buried + def custom_study(self, request: CustomStudyRequest) -> OpChanges: + return self.col._backend.custom_study(request) + def extend_limits(self, new: int, rev: int) -> None: did = self.col.decks.current()["id"] self.col._backend.extend_limits(deck_id=did, new_delta=new, review_delta=rev) diff --git a/qt/.pylintrc b/qt/.pylintrc index 6107eeed3..4068ea43e 100644 --- a/qt/.pylintrc +++ b/qt/.pylintrc @@ -15,6 +15,8 @@ ignored-classes= CardAnswer, QueuedCards, ChangeNotetypeRequest, + CustomStudyRequest, + Cram, [REPORTS] output-format=colorized diff --git a/qt/aqt/customstudy.py b/qt/aqt/customstudy.py index cfae832a4..f53710ddb 100644 --- a/qt/aqt/customstudy.py +++ b/qt/aqt/customstudy.py @@ -2,10 +2,12 @@ # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import aqt -from anki.collection import SearchNode from anki.consts import * +from anki.scheduler import CustomStudyRequest +from aqt.operations.scheduling import custom_study from aqt.qt import * -from aqt.utils import disable_help_button, showInfo, showWarning, tr +from aqt.taglimit import TagLimit +from aqt.utils import disable_help_button, tr RADIO_NEW = 1 RADIO_REV = 2 @@ -120,98 +122,39 @@ class CustomStudy(QDialog): self.radioIdx = idx def accept(self) -> None: - f = self.form - i = self.radioIdx - spin = f.spin.value() - if i == RADIO_NEW: - self.deck["extendNew"] = spin - self.mw.col.decks.save(self.deck) - self.mw.col.sched.extend_limits(spin, 0) - self.mw.reset() - QDialog.accept(self) - return - elif i == RADIO_REV: - self.deck["extendRev"] = spin - self.mw.col.decks.save(self.deck) - self.mw.col.sched.extend_limits(0, spin) - self.mw.reset() - QDialog.accept(self) - return - elif i == RADIO_CRAM: - tags = self._getTags() - # the rest create a filtered deck - cur = self.mw.col.decks.by_name(tr.custom_study_custom_study_session()) - if cur: - if not cur["dyn"]: - showInfo(tr.custom_study_must_rename_deck()) - QDialog.accept(self) - return - else: - # safe to empty - self.mw.col.sched.empty_filtered_deck(cur["id"]) - # reuse; don't delete as it may have children - dyn = cur - self.mw.col.decks.select(cur["id"]) + request = CustomStudyRequest() + if self.radioIdx == RADIO_NEW: + request.new_limit_delta = self.form.spin.value() + elif self.radioIdx == RADIO_REV: + request.review_limit_delta = self.form.spin.value() + elif self.radioIdx == RADIO_FORGOT: + request.forgot_days = self.form.spin.value() + elif self.radioIdx == RADIO_AHEAD: + request.review_ahead_days = self.form.spin.value() + elif self.radioIdx == RADIO_PREVIEW: + request.preview_days = self.form.spin.value() else: - did = self.mw.col.decks.new_filtered(tr.custom_study_custom_study_session()) - dyn = self.mw.col.decks.get(did) - # and then set various options - if i == RADIO_FORGOT: - search = self.mw.col.build_search_string( - SearchNode( - rated=SearchNode.Rated(days=spin, rating=SearchNode.RATING_AGAIN) - ) - ) - dyn["terms"][0] = [search, DYN_MAX_SIZE, DYN_RANDOM] - dyn["resched"] = False - elif i == RADIO_AHEAD: - search = self.mw.col.build_search_string(SearchNode(due_in_days=spin)) - dyn["terms"][0] = [search, DYN_MAX_SIZE, DYN_DUE] - dyn["resched"] = True - elif i == RADIO_PREVIEW: - search = self.mw.col.build_search_string( - SearchNode(card_state=SearchNode.CARD_STATE_NEW), - SearchNode(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 = self.mw.col.build_search_string( - SearchNode(card_state=SearchNode.CARD_STATE_NEW) - ) - ord = DYN_ADDED - dyn["resched"] = True - elif type == TYPE_DUE: - terms = self.mw.col.build_search_string( - SearchNode(card_state=SearchNode.CARD_STATE_DUE) - ) - ord = DYN_DUE - dyn["resched"] = True - elif type == TYPE_REVIEW: - terms = self.mw.col.build_search_string( - SearchNode(negated=SearchNode(card_state=SearchNode.CARD_STATE_NEW)) - ) - ord = DYN_RANDOM - dyn["resched"] = True + request.cram.card_limit = self.form.spin.value() + + tags = TagLimit.get_tags(self.mw, self) + request.cram.tags_to_include.extend(tags[0]) + request.cram.tags_to_exclude.extend(tags[1]) + + cram_type = self.form.cardType.currentRow() + if cram_type == TYPE_NEW: + request.cram.kind = CustomStudyRequest.Cram.CRAM_KIND_NEW + elif cram_type == TYPE_DUE: + request.cram.kind = CustomStudyRequest.Cram.CRAM_KIND_DUE + elif cram_type == TYPE_REVIEW: + request.cram.kind = CustomStudyRequest.Cram.CRAM_KIND_REVIEW else: - terms = "" - ord = DYN_RANDOM - dyn["resched"] = False - dyn["terms"][0] = [(terms + tags).strip(), spin, ord] - # add deck limit - dyn["terms"][0][0] = self.mw.col.build_search_string( - dyn["terms"][0][0], SearchNode(deck=self.deck["name"]) - ) - self.mw.col.decks.save(dyn) - # generate cards - self.created_custom_study = True - if not self.mw.col.sched.rebuild_filtered_deck(dyn["id"]): - showWarning(tr.custom_study_no_cards_matched_the_criteria_you()) - return - self.mw.moveToState("overview") - QDialog.accept(self) + request.cram.kind = CustomStudyRequest.Cram.CRAM_KIND_ALL + + # keep open on failure, as the cause was most likely an empty search + # result, which the user can remedy + custom_study(parent=self, request=request).success( + lambda _: QDialog.accept(self) + ).run_in_background() def reject(self) -> None: if self.created_custom_study: @@ -219,8 +162,3 @@ class CustomStudy(QDialog): self.mw.col.decks.select(self.deck["id"]) # fixme: clean up the empty custom study deck QDialog.reject(self) - - def _getTags(self) -> str: - from aqt.taglimit import TagLimit - - return TagLimit(self.mw, self).tags diff --git a/qt/aqt/operations/scheduling.py b/qt/aqt/operations/scheduling.py index 637ac4c92..1e8f27da2 100644 --- a/qt/aqt/operations/scheduling.py +++ b/qt/aqt/operations/scheduling.py @@ -17,7 +17,7 @@ from anki.collection import ( ) from anki.decks import DeckId from anki.notes import NoteId -from anki.scheduler import FilteredDeckForUpdate, UnburyDeck +from anki.scheduler import CustomStudyRequest, FilteredDeckForUpdate, UnburyDeck from anki.scheduler.v3 import CardAnswer from anki.scheduler.v3 import Scheduler as V3Scheduler from aqt.operations import CollectionOp @@ -228,3 +228,11 @@ def answer_card( return col.sched.answer_card(answer) return CollectionOp(parent, answer_v3) + + +def custom_study( + *, + parent: QWidget, + request: CustomStudyRequest, +) -> CollectionOp[OpChanges]: + return CollectionOp(parent, lambda col: col.sched.custom_study(request)) diff --git a/qt/aqt/taglimit.py b/qt/aqt/taglimit.py index a9db514a1..b996bc1d2 100644 --- a/qt/aqt/taglimit.py +++ b/qt/aqt/taglimit.py @@ -1,23 +1,31 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/copyleft/agpl.html -from typing import Optional +from __future__ import annotations + +from typing import List, Optional, Tuple import aqt from anki.lang import with_collapsed_whitespace -from aqt.customstudy import CustomStudy from aqt.main import AnkiQt from aqt.qt import * from aqt.utils import disable_help_button, restoreGeom, saveGeom, showWarning, tr class TagLimit(QDialog): - def __init__(self, mw: AnkiQt, parent: CustomStudy) -> None: + @staticmethod + def get_tags( + mw: AnkiQt, parent: aqt.customstudy.CustomStudy + ) -> Tuple[List[str], List[str]]: + """Get two lists of tags to include/exclude.""" + return TagLimit(mw, parent).tags + + def __init__(self, mw: AnkiQt, parent: aqt.customstudy.CustomStudy) -> None: QDialog.__init__(self, parent, Qt.WindowType.Window) - self.tags: str = "" + self.tags: Tuple[List[str], List[str]] = ([], []) self.tags_list: list[str] = [] self.mw = mw - self.parent_: Optional[CustomStudy] = parent + self.parent_: Optional[aqt.customstudy.CustomStudy] = parent self.deck = self.parent_.deck self.dialog = aqt.forms.taglimit.Ui_Dialog() self.dialog.setupUi(self) @@ -75,44 +83,35 @@ class TagLimit(QDialog): self.dialog.inactiveList.selectionModel().select(idx, mode) def reject(self) -> None: - self.tags = "" QDialog.reject(self) def accept(self) -> None: + include_tags = exclude_tags = [] # gather yes/no tags - yes = [] - no = [] for c in range(self.dialog.activeList.count()): # active if self.dialog.activeCheck.isChecked(): item = self.dialog.activeList.item(c) idx = self.dialog.activeList.indexFromItem(item) if self.dialog.activeList.selectionModel().isSelected(idx): - yes.append(self.tags_list[c]) + include_tags.append(self.tags_list[c]) # inactive item = self.dialog.inactiveList.item(c) idx = self.dialog.inactiveList.indexFromItem(item) if self.dialog.inactiveList.selectionModel().isSelected(idx): - no.append(self.tags_list[c]) - if (len(yes) + len(no)) > 100: + exclude_tags.append(self.tags_list[c]) + + if (len(include_tags) + len(exclude_tags)) > 100: showWarning(with_collapsed_whitespace(tr.errors_100_tags_max())) return + self.hide() + self.tags = (include_tags, exclude_tags) + # save in the deck for future invocations - self.deck["activeTags"] = yes - self.deck["inactiveTags"] = no + self.deck["activeTags"] = include_tags + self.deck["inactiveTags"] = exclude_tags self.mw.col.decks.save(self.deck) - # build query string - self.tags = "" - if yes: - arr = [] - for req in yes: - arr.append(f'tag:"{req}"') - self.tags += f"({' or '.join(arr)})" - if no: - arr = [] - for req in no: - arr.append(f'-tag:"{req}"') - self.tags += f" {' '.join(arr)}" + saveGeom(self, "tagLimit") QDialog.accept(self) diff --git a/rslib/BUILD.bazel b/rslib/BUILD.bazel index fa2bdf15c..6c3a8c49f 100644 --- a/rslib/BUILD.bazel +++ b/rslib/BUILD.bazel @@ -151,14 +151,14 @@ rust_test( rustfmt_test( name = "format_check", srcs = glob([ - "src/**/*.rs", + "**/*.rs", ]), ) rustfmt_fix( name = "format", srcs = glob([ - "src/**/*.rs", + "**/*.rs", ]), ) diff --git a/rslib/build/protobuf.rs b/rslib/build/protobuf.rs index 981f8f239..7b16a06af 100644 --- a/rslib/build/protobuf.rs +++ b/rslib/build/protobuf.rs @@ -105,10 +105,7 @@ pub fn write_backend_proto_rs() { "Deck.Filtered.SearchTerm.Order", "#[derive(strum::EnumIter)]", ) - .type_attribute( - "HelpPageLinkRequest.HelpPage", - "#[derive(strum::EnumIter)]", - ) + .type_attribute("HelpPageLinkRequest.HelpPage", "#[derive(strum::EnumIter)]") .compile_protos(paths.as_slice(), &[proto_dir]) .unwrap(); } diff --git a/rslib/empty.rs b/rslib/empty.rs index e69de29bb..8b1378917 100644 --- a/rslib/empty.rs +++ b/rslib/empty.rs @@ -0,0 +1 @@ + diff --git a/rslib/linkchecker/tests/links.rs b/rslib/linkchecker/tests/links.rs index 850793664..e9c63053f 100644 --- a/rslib/linkchecker/tests/links.rs +++ b/rslib/linkchecker/tests/links.rs @@ -85,4 +85,4 @@ mod test { .join("\n - ") } } -} \ No newline at end of file +} diff --git a/rslib/src/backend/error.rs b/rslib/src/backend/error.rs index 8a5e8e676..382762b79 100644 --- a/rslib/src/backend/error.rs +++ b/rslib/src/backend/error.rs @@ -33,6 +33,7 @@ impl AnkiError { AnkiError::UndoEmpty => Kind::UndoEmpty, AnkiError::MultipleNotetypesSelected => Kind::InvalidInput, AnkiError::DatabaseCheckRequired => Kind::InvalidInput, + AnkiError::CustomStudyError(_) => Kind::CustomStudyError, }; pb::BackendError { diff --git a/rslib/src/backend/scheduler/mod.rs b/rslib/src/backend/scheduler/mod.rs index 05d4dbdda..77625ab1b 100644 --- a/rslib/src/backend/scheduler/mod.rs +++ b/rslib/src/backend/scheduler/mod.rs @@ -182,6 +182,10 @@ impl SchedulerService for Backend { .map(Into::into) }) } + + fn custom_study(&self, input: pb::CustomStudyRequest) -> Result { + self.with_col(|col| col.custom_study(input)).map(Into::into) + } } impl From for pb::SchedTimingTodayResponse { diff --git a/rslib/src/backend/search/mod.rs b/rslib/src/backend/search/mod.rs index 40e4c7f8a..e9026757b 100644 --- a/rslib/src/backend/search/mod.rs +++ b/rslib/src/backend/search/mod.rs @@ -13,13 +13,13 @@ use crate::{ backend_proto::sort_order::Value as SortOrderProto, browser_table::Column, prelude::*, - search::{concatenate_searches, replace_search_node, write_nodes, Node, SortMode}, + search::{replace_search_node, Node, SortMode}, }; impl SearchService for Backend { fn build_search_string(&self, input: pb::SearchNode) -> Result { let node: Node = input.try_into()?; - Ok(write_nodes(&node.into_node_list()).into()) + Ok(SearchBuilder::from_root(node).write().into()) } fn search_cards(&self, input: pb::SearchRequest) -> Result { @@ -43,13 +43,18 @@ impl SearchService for Backend { } fn join_search_nodes(&self, input: pb::JoinSearchNodesRequest) -> Result { - let sep = input.joiner().into(); - let existing_nodes = { - let node: Node = input.existing_node.unwrap_or_default().try_into()?; - node.into_node_list() - }; - let additional_node = input.additional_node.unwrap_or_default().try_into()?; - Ok(concatenate_searches(sep, existing_nodes, additional_node).into()) + let existing_node: Node = input.existing_node.unwrap_or_default().try_into()?; + let additional_node: Node = input.additional_node.unwrap_or_default().try_into()?; + let search = SearchBuilder::from_root(existing_node); + + Ok( + match pb::search_node::group::Joiner::from_i32(input.joiner).unwrap_or_default() { + pb::search_node::group::Joiner::And => search.and(additional_node), + pb::search_node::group::Joiner::Or => search.or(additional_node), + } + .write() + .into(), + ) } fn replace_search_node(&self, input: pb::ReplaceSearchNodeRequest) -> Result { diff --git a/rslib/src/backend/search/search_node.rs b/rslib/src/backend/search/search_node.rs index 02a2c906c..469cc7391 100644 --- a/rslib/src/backend/search/search_node.rs +++ b/rslib/src/backend/search/search_node.rs @@ -7,8 +7,7 @@ use crate::{ backend_proto as pb, prelude::*, search::{ - parse_search, BoolSeparator, Node, PropertyKind, RatingKind, SearchNode, StateKind, - TemplateKind, + parse_search, Negated, Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind, }, text::escape_anki_wildcards_for_search_node, }; @@ -20,15 +19,9 @@ impl TryFrom for Node { use pb::search_node::{group::Joiner, Filter, Flag}; Ok(if let Some(filter) = msg.filter { match filter { - Filter::Tag(s) => { - Node::Search(SearchNode::Tag(escape_anki_wildcards_for_search_node(&s))) - } - Filter::Deck(s) => { - Node::Search(SearchNode::Deck(escape_anki_wildcards_for_search_node(&s))) - } - Filter::Note(s) => Node::Search(SearchNode::Notetype( - escape_anki_wildcards_for_search_node(&s), - )), + Filter::Tag(s) => SearchNode::from_tag_name(&s).into(), + Filter::Deck(s) => SearchNode::from_deck_name(&s).into(), + Filter::Note(s) => SearchNode::from_notetype_name(&s).into(), Filter::Template(u) => { Node::Search(SearchNode::CardTemplate(TemplateKind::Ordinal(u as u16))) } @@ -112,15 +105,6 @@ impl TryFrom for Node { } } -impl From for BoolSeparator { - fn from(sep: pb::search_node::group::Joiner) -> Self { - match sep { - pb::search_node::group::Joiner::And => BoolSeparator::And, - pb::search_node::group::Joiner::Or => BoolSeparator::Or, - } - } -} - impl From for RatingKind { fn from(r: pb::search_node::Rating) -> Self { match r { diff --git a/rslib/src/error/filtered.rs b/rslib/src/error/filtered.rs index b321ff337..e5856b474 100644 --- a/rslib/src/error/filtered.rs +++ b/rslib/src/error/filtered.rs @@ -32,3 +32,25 @@ impl From for AnkiError { AnkiError::FilteredDeckError(e) } } + +#[derive(Debug, PartialEq)] +pub enum CustomStudyError { + NoMatchingCards, + ExistingDeck, +} + +impl CustomStudyError { + pub fn localized_description(&self, tr: &I18n) -> String { + match self { + Self::NoMatchingCards => tr.custom_study_no_cards_matched_the_criteria_you(), + Self::ExistingDeck => tr.custom_study_must_rename_deck(), + } + .into() + } +} + +impl From for AnkiError { + fn from(e: CustomStudyError) -> Self { + AnkiError::CustomStudyError(e) + } +} diff --git a/rslib/src/error/mod.rs b/rslib/src/error/mod.rs index 907bf2492..0c67f36c7 100644 --- a/rslib/src/error/mod.rs +++ b/rslib/src/error/mod.rs @@ -9,7 +9,7 @@ mod search; use std::{fmt::Display, io}; pub use db::{DbError, DbErrorKind}; -pub use filtered::FilteredDeckError; +pub use filtered::{CustomStudyError, FilteredDeckError}; pub use network::{NetworkError, NetworkErrorKind, SyncError, SyncErrorKind}; pub use search::{ParseError, SearchErrorKind}; use tempfile::PathPersistError; @@ -41,6 +41,7 @@ pub enum AnkiError { UndoEmpty, MultipleNotetypesSelected, DatabaseCheckRequired, + CustomStudyError(CustomStudyError), } impl Display for AnkiError { @@ -94,6 +95,7 @@ impl AnkiError { AnkiError::InvalidRegex(err) => format!("
{}
", err), AnkiError::MultipleNotetypesSelected => tr.errors_multiple_notetypes_selected().into(), AnkiError::DatabaseCheckRequired => tr.errors_please_check_database().into(), + AnkiError::CustomStudyError(err) => err.localized_description(tr), AnkiError::IoError(_) | AnkiError::JsonError(_) | AnkiError::ProtoError(_) diff --git a/rslib/src/notetype/mod.rs b/rslib/src/notetype/mod.rs index f3bf11994..9cc8b4f8a 100644 --- a/rslib/src/notetype/mod.rs +++ b/rslib/src/notetype/mod.rs @@ -222,7 +222,7 @@ impl Collection { .ok_or(AnkiError::NotFound)?; if self - .search_notes_unordered(match_all![note1.notetype_id, nids_node])? + .search_notes_unordered(SearchBuilder::from(note1.notetype_id).and(nids_node))? .len() != note_ids.len() { diff --git a/rslib/src/notetype/notetypechange.rs b/rslib/src/notetype/notetypechange.rs index a910c6ca0..61ba43ee5 100644 --- a/rslib/src/notetype/notetypechange.rs +++ b/rslib/src/notetype/notetypechange.rs @@ -292,13 +292,12 @@ impl Collection { usn: Usn, ) -> Result<(), AnkiError> { if !map.removed.is_empty() { - let ords = Node::any( - map.removed - .iter() - .map(|o| TemplateKind::Ordinal(*o as u16)) - .map(Into::into), - ); - self.search_cards_into_table(match_all![nids, ords], SortMode::NoOrder)?; + let ords = + SearchBuilder::any(map.removed.iter().map(|o| TemplateKind::Ordinal(*o as u16))); + self.search_cards_into_table( + SearchBuilder::from(nids).and_join(&mut ords.group()), + SortMode::NoOrder, + )?; for card in self.storage.all_searched_cards()? { self.remove_card_and_add_grave_undoable(card, usn)?; } @@ -315,13 +314,15 @@ impl Collection { usn: Usn, ) -> Result<(), AnkiError> { if !map.remapped.is_empty() { - let ords = Node::any( + let mut ords = SearchBuilder::any( map.remapped .keys() - .map(|o| TemplateKind::Ordinal(*o as u16)) - .map(Into::into), + .map(|o| TemplateKind::Ordinal(*o as u16)), ); - self.search_cards_into_table(match_all![nids, ords], SortMode::NoOrder)?; + self.search_cards_into_table( + SearchBuilder::from(nids).and_join(&mut ords), + SortMode::NoOrder, + )?; for mut card in self.storage.all_searched_cards()? { let original = card.clone(); card.template_idx = diff --git a/rslib/src/notetype/schemachange.rs b/rslib/src/notetype/schemachange.rs index 88e065005..630712833 100644 --- a/rslib/src/notetype/schemachange.rs +++ b/rslib/src/notetype/schemachange.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use super::{CardGenContext, Notetype}; use crate::{ prelude::*, - search::{Node, SortMode, TemplateKind}, + search::{SortMode, TemplateKind}, }; /// True if any ordinals added, removed or reordered. @@ -143,14 +143,12 @@ impl Collection { // remove any cards where the template was deleted if !changes.removed.is_empty() { - let ords = Node::any( - changes - .removed - .into_iter() - .map(TemplateKind::Ordinal) - .map(Into::into), - ); - self.search_cards_into_table(match_all![nt.id, ords], SortMode::NoOrder)?; + let mut ords = + SearchBuilder::any(changes.removed.into_iter().map(TemplateKind::Ordinal)); + self.search_cards_into_table( + SearchBuilder::from(nt.id).and_join(&mut ords), + SortMode::NoOrder, + )?; for card in self.storage.all_searched_cards()? { self.remove_card_and_add_grave_undoable(card, usn)?; } @@ -159,15 +157,12 @@ impl Collection { // update ordinals for cards with a repositioned template if !changes.moved.is_empty() { - let ords = Node::any( - changes - .moved - .keys() - .cloned() - .map(TemplateKind::Ordinal) - .map(Into::into), - ); - self.search_cards_into_table(match_all![nt.id, ords], SortMode::NoOrder)?; + let mut ords = + SearchBuilder::any(changes.moved.keys().cloned().map(TemplateKind::Ordinal)); + self.search_cards_into_table( + SearchBuilder::from(nt.id).and_join(&mut ords), + SortMode::NoOrder, + )?; for mut card in self.storage.all_searched_cards()? { let original = card.clone(); card.template_idx = *changes.moved.get(&card.template_idx).unwrap(); diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index 394f2d310..e6600790e 100644 --- a/rslib/src/ops.rs +++ b/rslib/src/ops.rs @@ -14,6 +14,7 @@ pub enum Op { Bury, ChangeNotetype, ClearUnusedTags, + CreateCustomStudy, EmptyFilteredDeck, FindAndReplace, RebuildFilteredDeck, @@ -53,6 +54,7 @@ impl Op { Op::AddNote => tr.actions_add_note(), Op::AnswerCard => tr.actions_answer_card(), Op::Bury => tr.studying_bury(), + Op::CreateCustomStudy => tr.actions_custom_study(), Op::RemoveDeck => tr.decks_delete_deck(), Op::RemoveNote => tr.studying_delete_note(), Op::RenameDeck => tr.actions_rename_deck(), diff --git a/rslib/src/prelude.rs b/rslib/src/prelude.rs index f749d1fdc..721aaaf52 100644 --- a/rslib/src/prelude.rs +++ b/rslib/src/prelude.rs @@ -12,12 +12,11 @@ pub use crate::{ decks::{Deck, DeckId, DeckKind, NativeDeckName}, error::{AnkiError, Result}, i18n::I18n, - match_all, match_any, notes::{Note, NoteId}, notetype::{Notetype, NotetypeId}, ops::{Op, OpChanges, OpOutput}, revlog::RevlogId, - search::TryIntoSearch, + search::{SearchBuilder, TryIntoSearch}, timestamp::{TimestampMillis, TimestampSecs}, types::Usn, }; diff --git a/rslib/src/scheduler/bury_and_suspend.rs b/rslib/src/scheduler/bury_and_suspend.rs index 24f7c3193..854da86f5 100644 --- a/rslib/src/scheduler/bury_and_suspend.rs +++ b/rslib/src/scheduler/bury_and_suspend.rs @@ -79,7 +79,7 @@ impl Collection { }; self.transact(Op::UnburyUnsuspend, |col| { col.search_cards_into_table( - match_all![SearchNode::DeckIdWithChildren(deck_id), state], + SearchBuilder::from(SearchNode::DeckIdWithChildren(deck_id)).and(state), SortMode::NoOrder, )?; col.unsuspend_or_unbury_searched_cards() diff --git a/rslib/src/scheduler/filtered/custom_study.rs b/rslib/src/scheduler/filtered/custom_study.rs new file mode 100644 index 000000000..a617fe5f2 --- /dev/null +++ b/rslib/src/scheduler/filtered/custom_study.rs @@ -0,0 +1,188 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::FilteredDeckForUpdate; +use crate::{ + backend_proto::{ + self as pb, + custom_study_request::{cram::CramKind, Cram, Value as CustomStudyValue}, + }, + decks::{FilteredDeck, FilteredSearchOrder, FilteredSearchTerm}, + error::{CustomStudyError, FilteredDeckError}, + prelude::*, + search::{Negated, PropertyKind, RatingKind, SearchNode, StateKind}, +}; + +impl Collection { + pub fn custom_study(&mut self, input: pb::CustomStudyRequest) -> Result> { + self.transact(Op::CreateCustomStudy, |col| col.custom_study_inner(input)) + } +} + +impl Collection { + fn custom_study_inner(&mut self, input: pb::CustomStudyRequest) -> Result<()> { + let current_deck = self.get_current_deck()?; + + match input + .value + .ok_or_else(|| AnkiError::invalid_input("missing oneof value"))? + { + CustomStudyValue::NewLimitDelta(delta) => { + let today = self.current_due_day(0)?; + self.extend_limits(today, self.usn()?, current_deck.id, delta, 0) + } + CustomStudyValue::ReviewLimitDelta(delta) => { + let today = self.current_due_day(0)?; + self.extend_limits(today, self.usn()?, current_deck.id, 0, delta) + } + CustomStudyValue::ForgotDays(days) => { + self.create_custom_study_deck(forgot_config(current_deck.human_name(), days)) + } + CustomStudyValue::ReviewAheadDays(days) => { + self.create_custom_study_deck(ahead_config(current_deck.human_name(), days)) + } + CustomStudyValue::PreviewDays(days) => { + self.create_custom_study_deck(preview_config(current_deck.human_name(), days)) + } + CustomStudyValue::Cram(cram) => { + self.create_custom_study_deck(cram_config(current_deck.human_name(), cram)?) + } + } + } + + /// Reuse existing one or create new one if missing. + /// Guaranteed to be a filtered deck. + fn create_custom_study_deck(&mut self, config: FilteredDeck) -> Result<()> { + let mut id = DeckId(0); + let human_name = self.tr.custom_study_custom_study_session().to_string(); + + if let Some(did) = self.get_deck_id(&human_name)? { + if !self + .get_deck(did)? + .ok_or(AnkiError::NotFound)? + .is_filtered() + { + return Err(CustomStudyError::ExistingDeck.into()); + } + id = did; + } + + let deck = FilteredDeckForUpdate { + id, + human_name, + config, + }; + + self.add_or_update_filtered_deck_inner(deck) + .map(|_| ()) + .map_err(|err| { + if err == AnkiError::FilteredDeckError(FilteredDeckError::SearchReturnedNoCards) { + CustomStudyError::NoMatchingCards.into() + } else { + err + } + }) + } +} + +fn custom_study_config( + reschedule: bool, + search: String, + order: FilteredSearchOrder, + limit: Option, +) -> FilteredDeck { + FilteredDeck { + reschedule, + search_terms: vec![FilteredSearchTerm { + search, + limit: limit.unwrap_or(99_999), + order: order as i32, + }], + delays: vec![], + preview_delay: 10, + } +} + +fn forgot_config(deck_name: String, days: u32) -> FilteredDeck { + let search = SearchBuilder::from(SearchNode::Rated { + days, + ease: RatingKind::AnswerButton(1), + }) + .and(SearchNode::from_deck_name(&deck_name)) + .write(); + custom_study_config(false, search, FilteredSearchOrder::Random, None) +} + +fn ahead_config(deck_name: String, days: u32) -> FilteredDeck { + let search = SearchBuilder::from(SearchNode::Property { + operator: "<=".to_string(), + kind: PropertyKind::Due(days as i32), + }) + .and(SearchNode::from_deck_name(&deck_name)) + .write(); + custom_study_config(true, search, FilteredSearchOrder::Due, None) +} + +fn preview_config(deck_name: String, days: u32) -> FilteredDeck { + let search = SearchBuilder::from(StateKind::New) + .and(SearchNode::AddedInDays(days)) + .and(SearchNode::from_deck_name(&deck_name)) + .write(); + custom_study_config( + false, + search, + FilteredSearchOrder::OldestReviewedFirst, + None, + ) +} + +fn cram_config(deck_name: String, cram: Cram) -> Result { + let (reschedule, nodes, order) = match CramKind::from_i32(cram.kind).unwrap_or_default() { + CramKind::New => ( + true, + SearchBuilder::from(StateKind::New), + FilteredSearchOrder::Added, + ), + CramKind::Due => ( + true, + SearchBuilder::from(StateKind::Due), + FilteredSearchOrder::Due, + ), + CramKind::Review => ( + true, + SearchBuilder::from(StateKind::New.negated()), + FilteredSearchOrder::Random, + ), + CramKind::All => (false, SearchBuilder::new(), FilteredSearchOrder::Random), + }; + + let search = nodes + .and_join(&mut tags_to_nodes( + cram.tags_to_include, + cram.tags_to_exclude, + )) + .and(SearchNode::from_deck_name(&deck_name)) + .write(); + + Ok(custom_study_config( + reschedule, + search, + order, + Some(cram.card_limit), + )) +} + +fn tags_to_nodes(tags_to_include: Vec, tags_to_exclude: Vec) -> SearchBuilder { + let include_nodes = SearchBuilder::any( + tags_to_include + .iter() + .map(|tag| SearchNode::from_tag_name(tag)), + ); + let mut exclude_nodes = SearchBuilder::all( + tags_to_exclude + .iter() + .map(|tag| SearchNode::from_tag_name(tag).negated()), + ); + + include_nodes.group().and_join(&mut exclude_nodes) +} diff --git a/rslib/src/scheduler/filtered/mod.rs b/rslib/src/scheduler/filtered/mod.rs index f8710dc42..5c715567a 100644 --- a/rslib/src/scheduler/filtered/mod.rs +++ b/rslib/src/scheduler/filtered/mod.rs @@ -2,6 +2,7 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html mod card; +mod custom_study; use crate::{ config::{ConfigKey, SchedulerVersion}, diff --git a/rslib/src/scheduler/new.rs b/rslib/src/scheduler/new.rs index 097dbcf98..48b99b138 100644 --- a/rslib/src/scheduler/new.rs +++ b/rslib/src/scheduler/new.rs @@ -196,7 +196,7 @@ impl Collection { usn: Usn, ) -> Result { let cids = self.search_cards( - match_all![SearchNode::DeckIdWithoutChildren(deck), StateKind::New], + SearchBuilder::from(SearchNode::DeckIdWithoutChildren(deck)).and(StateKind::New), SortMode::NoOrder, )?; self.sort_cards_inner(&cids, 1, 1, order.into(), false, usn) diff --git a/rslib/src/search/builder.rs b/rslib/src/search/builder.rs new file mode 100644 index 000000000..fae6211ed --- /dev/null +++ b/rslib/src/search/builder.rs @@ -0,0 +1,213 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::mem; + +use itertools::Itertools; + +use super::{writer::write_nodes, Node, SearchNode, StateKind, TemplateKind}; +use crate::{prelude::*, text::escape_anki_wildcards_for_search_node}; + +pub trait Negated { + fn negated(self) -> Node; +} + +impl> Negated for T { + fn negated(self) -> Node { + let node: Node = self.into(); + if let Node::Not(inner) = node { + *inner + } else { + Node::Not(Box::new(node)) + } + } +} + +/// Helper to programmatically build searches. +#[derive(Debug, PartialEq, Clone)] +pub struct SearchBuilder(Vec); + +impl SearchBuilder { + pub fn new() -> Self { + Self(vec![]) + } + + /// Construct [SearchBuilder] with this [Node], or its inner [Node]s, + /// if it is a [Node::Group] + pub fn from_root(node: Node) -> Self { + match node { + Node::Group(nodes) => Self(nodes), + _ => Self(vec![node]), + } + } + + /// Construct [SearchBuilder] where given [Node]s are joined by [Node::And]. + pub fn all(iter: impl IntoIterator>) -> Self { + Self(Itertools::intersperse(iter.into_iter().map(Into::into), Node::And).collect()) + } + + /// Construct [SearchBuilder] where given [Node]s are joined by [Node::Or]. + pub fn any(iter: impl IntoIterator>) -> Self { + Self(Itertools::intersperse(iter.into_iter().map(Into::into), Node::Or).collect()) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn and>(mut self, node: N) -> Self { + if !self.is_empty() { + self.0.push(Node::And) + } + self.0.push(node.into()); + self + } + + pub fn or>(mut self, node: N) -> Self { + if !self.is_empty() { + self.0.push(Node::Or) + } + self.0.push(node.into()); + self + } + + /// Wrap [Node]s in [Node::Group] if there is more than 1. + pub fn group(mut self) -> Self { + if self.len() > 1 { + self.0 = vec![Node::Group(mem::take(&mut self.0))]; + } + self + } + + /// Concatenate [Node]s of `other`, inserting [Node::And] if appropriate. + /// No implicit grouping is done. + pub fn and_join(mut self, other: &mut Self) -> Self { + if !(self.is_empty() || other.is_empty()) { + self.0.push(Node::And); + } + self.0.append(&mut other.0); + self + } + + /// Concatenate [Node]s of `other`, inserting [Node::Or] if appropriate. + /// No implicit grouping is done. + pub fn or_join(mut self, other: &mut Self) -> Self { + if !(self.is_empty() || other.is_empty()) { + self.0.push(Node::And); + } + self.0.append(&mut other.0); + self + } + + pub fn write(&self) -> String { + write_nodes(&self.0) + } +} + +impl> From for SearchBuilder { + fn from(node: T) -> Self { + Self(vec![node.into()]) + } +} + +impl TryIntoSearch for SearchBuilder { + fn try_into_search(self) -> Result { + Ok(self.group().0.remove(0)) + } +} + +impl Default for SearchBuilder { + fn default() -> Self { + Self::new() + } +} + +impl Node { + /// If we're a group, return the contained elements. + /// If we're a single node, return ourselves in an one-element vec. + pub fn into_node_list(self) -> Vec { + if let Node::Group(nodes) = self { + nodes + } else { + vec![self] + } + } +} + +impl SearchNode { + /// Construct [SearchNode] from an unescaped deck name. + pub fn from_deck_name(name: &str) -> Self { + Self::Deck(escape_anki_wildcards_for_search_node(name)) + } + + /// Construct [SearchNode] from an unescaped tag name. + pub fn from_tag_name(name: &str) -> Self { + Self::Tag(escape_anki_wildcards_for_search_node(name)) + } + + /// Construct [SearchNode] from an unescaped notetype name. + pub fn from_notetype_name(name: &str) -> Self { + Self::Notetype(escape_anki_wildcards_for_search_node(name)) + } + + /// Construct [SearchNode] from an unescaped template name. + pub fn from_template_name(name: &str) -> Self { + Self::CardTemplate(TemplateKind::Name(escape_anki_wildcards_for_search_node( + name, + ))) + } +} + +impl> From for Node { + fn from(node: T) -> Self { + Self::Search(node.into()) + } +} + +impl From for SearchNode { + fn from(id: NotetypeId) -> Self { + SearchNode::NotetypeId(id) + } +} + +impl From for SearchNode { + fn from(k: TemplateKind) -> Self { + SearchNode::CardTemplate(k) + } +} + +impl From for SearchNode { + fn from(n: NoteId) -> Self { + SearchNode::NoteIds(format!("{}", n)) + } +} + +impl From for SearchNode { + fn from(k: StateKind) -> Self { + SearchNode::State(k) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn negating() { + let node = Node::Search(SearchNode::UnqualifiedText("foo".to_string())); + let neg_node = Node::Not(Box::new(Node::Search(SearchNode::UnqualifiedText( + "foo".to_string(), + )))); + assert_eq!(node.clone().negated(), neg_node); + assert_eq!(node.clone().negated().negated(), node); + + assert_eq!( + StateKind::Due.negated(), + Node::Not(Box::new(Node::Search(SearchNode::State(StateKind::Due)))) + ) + } +} diff --git a/rslib/src/search/mod.rs b/rslib/src/search/mod.rs index 7f85bc841..b47ba074b 100644 --- a/rslib/src/search/mod.rs +++ b/rslib/src/search/mod.rs @@ -1,18 +1,21 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +mod builder; mod parser; mod sqlwriter; pub(crate) mod writer; use std::borrow::Cow; +use rusqlite::{params_from_iter, types::FromSql}; +use sqlwriter::{RequiredTable, SqlWriter}; + +pub use builder::{Negated, SearchBuilder}; pub use parser::{ parse as parse_search, Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind, }; -use rusqlite::{params_from_iter, types::FromSql}; -use sqlwriter::{RequiredTable, SqlWriter}; -pub use writer::{concatenate_searches, replace_search_node, write_nodes, BoolSeparator}; +pub use writer::replace_search_node; use crate::{ browser_table::Column, diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index c36e8fa14..bc6997ef2 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -1,7 +1,6 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use itertools::Itertools; use lazy_static::lazy_static; use nom::{ branch::alt, @@ -39,48 +38,6 @@ pub enum Node { Search(SearchNode), } -impl Node { - pub fn negated(self) -> Node { - if let Node::Not(inner) = self { - *inner - } else { - Node::Not(Box::new(self)) - } - } - - /// If we're a group, return the contained elements. - /// If we're a single node, return ourselves in an one-element vec. - pub fn into_node_list(self) -> Vec { - if let Node::Group(nodes) = self { - nodes - } else { - vec![self] - } - } - - pub fn all(iter: impl IntoIterator) -> Node { - Node::Group(Itertools::intersperse(iter.into_iter(), Node::And).collect()) - } - - pub fn any(iter: impl IntoIterator) -> Node { - Node::Group(Itertools::intersperse(iter.into_iter(), Node::Or).collect()) - } -} - -#[macro_export] -macro_rules! match_all { - ($($param:expr),+ $(,)?) => { - $crate::search::Node::all(vec![$($param.into()),+]) - }; -} - -#[macro_export] -macro_rules! match_any { - ($($param:expr),+ $(,)?) => { - $crate::search::Node::any(vec![$($param.into()),+]) - }; -} - #[derive(Debug, PartialEq, Clone)] pub enum SearchNode { // text without a colon @@ -177,36 +134,6 @@ pub fn parse(input: &str) -> Result> { } } -impl From for Node { - fn from(n: SearchNode) -> Self { - Node::Search(n) - } -} - -impl From for Node { - fn from(id: NotetypeId) -> Self { - Node::Search(SearchNode::NotetypeId(id)) - } -} - -impl From for Node { - fn from(k: TemplateKind) -> Self { - Node::Search(SearchNode::CardTemplate(k)) - } -} - -impl From for Node { - fn from(n: NoteId) -> Self { - Node::Search(SearchNode::NoteIds(format!("{}", n))) - } -} - -impl From for Node { - fn from(k: StateKind) -> Self { - Node::Search(SearchNode::State(k)) - } -} - /// Zero or more nodes inside brackets, eg 'one OR two -three'. /// Empty vec must be handled by caller. fn group_inner(input: &str) -> IResult> { @@ -1106,14 +1033,4 @@ mod test { SearchErrorKind::InvalidNumber { .. } )); } - - #[test] - fn negating() { - let node = Node::Search(SearchNode::UnqualifiedText("foo".to_string())); - let neg_node = Node::Not(Box::new(Node::Search(SearchNode::UnqualifiedText( - "foo".to_string(), - )))); - assert_eq!(node.clone().negated(), neg_node); - assert_eq!(node.clone().negated().negated(), node); - } } diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs index 0b700d915..51f60f4f5 100644 --- a/rslib/src/search/writer.rs +++ b/rslib/src/search/writer.rs @@ -14,32 +14,6 @@ use crate::{ text::escape_anki_wildcards, }; -#[derive(Debug, PartialEq)] -pub enum BoolSeparator { - And, - Or, -} - -/// Take an existing search, and AND/OR it with the provided additional search. -/// This is required because when the user has "a AND b" in an existing search and -/// wants to add "c", we want "a AND b AND c", not "(a AND b) AND C", which is what we'd -/// get if we tried to join the existing search string with a new SearchTerm on the -/// client side. -pub fn concatenate_searches( - sep: BoolSeparator, - mut existing: Vec, - additional: Node, -) -> String { - if !existing.is_empty() { - existing.push(match sep { - BoolSeparator::And => Node::And, - BoolSeparator::Or => Node::Or, - }); - } - existing.push(additional); - write_nodes(&existing) -} - /// Given an existing parsed search, if the provided `replacement` is a single search node such /// as a deck:xxx search, replace any instances of that search in `existing` with the new value. /// Then return the possibly modified first search as a string. @@ -65,7 +39,7 @@ pub fn replace_search_node(mut existing: Vec, replacement: Node) -> String write_nodes(&existing) } -pub fn write_nodes(nodes: &[Node]) -> String { +pub(super) fn write_nodes(nodes: &[Node]) -> String { nodes.iter().map(write_node).collect() } @@ -234,42 +208,6 @@ mod test { assert_eq!("prop:ease>1", normalize_search("prop:ease>1.0").unwrap()); } - #[test] - fn concatenating() { - assert_eq!( - concatenate_searches( - BoolSeparator::And, - vec![Node::Search(SearchNode::UnqualifiedText("foo".to_string()))], - Node::Search(SearchNode::UnqualifiedText("bar".to_string())) - ), - "foo bar", - ); - assert_eq!( - concatenate_searches( - BoolSeparator::Or, - vec![Node::Search(SearchNode::UnqualifiedText("foo".to_string()))], - Node::Search(SearchNode::UnqualifiedText("bar".to_string())) - ), - "foo OR bar", - ); - assert_eq!( - concatenate_searches( - BoolSeparator::Or, - vec![Node::Search(SearchNode::WholeCollection)], - Node::Search(SearchNode::UnqualifiedText("bar".to_string())) - ), - "deck:* OR bar", - ); - assert_eq!( - concatenate_searches( - BoolSeparator::Or, - vec![], - Node::Search(SearchNode::UnqualifiedText("bar".to_string())) - ), - "bar", - ); - } - #[test] fn replacing() -> Result<()> { assert_eq!(