diff --git a/ftl/core/undo.ftl b/ftl/core/undo.ftl index 61d60d945..c438a2679 100644 --- a/ftl/core/undo.ftl +++ b/ftl/core/undo.ftl @@ -19,3 +19,4 @@ undo-update-card = Update Card undo-update-deck = Update Deck undo-forget-card = Forget Card undo-set-flag = Set Flag +undo-build-filtered-deck = Build Deck diff --git a/pylib/anki/decks.py b/pylib/anki/decks.py index ddc7bfc6d..c2c704de4 100644 --- a/pylib/anki/decks.py +++ b/pylib/anki/decks.py @@ -433,9 +433,9 @@ class DeckManager: "The currrently active dids." return self.col.get_config("activeDecks", [1]) - def selected(self) -> int: + def selected(self) -> DeckID: "The currently selected did." - return int(self.col.conf["curDeck"]) + return DeckID(int(self.col.conf["curDeck"])) def current(self) -> Deck: return self.get(self.selected()) diff --git a/pylib/anki/scheduler/base.py b/pylib/anki/scheduler/base.py index 4b4af388f..82201c7ad 100644 --- a/pylib/anki/scheduler/base.py +++ b/pylib/anki/scheduler/base.py @@ -71,6 +71,7 @@ class SchedulerBase: # fixme: used by custom study def totalRevForCurrentDeck(self) -> int: + assert self.col.db return self.col.db.scalar( f""" select count() from cards where id in ( @@ -88,11 +89,11 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l # Filtered deck handling ########################################################################## - def rebuild_filtered_deck(self, deck_id: int) -> int: + def rebuild_filtered_deck(self, deck_id: int) -> OpChangesWithCount: return self.col._backend.rebuild_filtered_deck(deck_id) - def empty_filtered_deck(self, deck_id: int) -> None: - self.col._backend.empty_filtered_deck(deck_id) + def empty_filtered_deck(self, deck_id: int) -> OpChanges: + return self.col._backend.empty_filtered_deck(deck_id) # Suspending & burying ########################################################################## @@ -140,17 +141,22 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l """Set cards to be due in `days`, turning them into review cards if necessary. `days` can be of the form '5' or '5..7' If `config_key` is provided, provided days will be remembered in config.""" + key: Optional[Config.String] if config_key: key = Config.String(key=config_key) else: key = None return self.col._backend.set_due_date( - card_ids=card_ids, days=days, config_key=key + card_ids=card_ids, + days=days, + # this value is optional; the auto-generated typing is wrong + config_key=key, # type: ignore ) def resetCards(self, ids: List[int]) -> None: "Completely reset cards for export." sids = ids2str(ids) + assert self.col.db # we want to avoid resetting due number of existing new cards on export nonNew = self.col.db.list( f"select id from cards where id in %s and (queue != {QUEUE_TYPE_NEW} or type != {CARD_TYPE_NEW})" diff --git a/pylib/anki/scheduler/legacy.py b/pylib/anki/scheduler/legacy.py index de4beac17..a00ad1d0b 100644 --- a/pylib/anki/scheduler/legacy.py +++ b/pylib/anki/scheduler/legacy.py @@ -50,7 +50,7 @@ class SchedulerBaseWithLegacy(SchedulerBase): def rebuildDyn(self, did: Optional[int] = None) -> Optional[int]: did = did or self.col.decks.selected() - count = self.rebuild_filtered_deck(did) or None + count = self.rebuild_filtered_deck(did).count or None if not count: return None # and change to our new deck diff --git a/pylib/mypy.ini b/pylib/mypy.ini index 074377dad..c2e786baa 100644 --- a/pylib/mypy.ini +++ b/pylib/mypy.ini @@ -16,6 +16,8 @@ disallow_untyped_defs = False [mypy-anki.exporting] disallow_untyped_defs = False +[mypy-anki.scheduler.base] +no_strict_optional = false [mypy-win32file] ignore_missing_imports = True diff --git a/qt/aqt/overview.py b/qt/aqt/overview.py index 9bb908a48..b294ad284 100644 --- a/qt/aqt/overview.py +++ b/qt/aqt/overview.py @@ -8,6 +8,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple import aqt from anki.collection import OpChanges from aqt import gui_hooks +from aqt.scheduling_ops import empty_filtered_deck, rebuild_filtered_deck from aqt.sound import av_player from aqt.toolbar import BottomBar from aqt.utils import TR, askUserDialog, openLink, shortcut, tooltip, tr @@ -52,12 +53,12 @@ class Overview: self.refresh() def refresh(self) -> None: + self._refresh_needed = False self.mw.col.reset() self._renderPage() self._renderBottom() self.mw.web.setFocus() gui_hooks.overview_did_refresh(self) - self._refresh_needed = False def refresh_if_needed(self) -> None: if self._refresh_needed: @@ -88,11 +89,9 @@ class Overview: elif url == "cram": aqt.dialogs.open("DynDeckConfDialog", self.mw) elif url == "refresh": - self.mw.col.sched.rebuild_filtered_deck(self.mw.col.decks.selected()) - self.mw.reset() + self.rebuild_current_filtered_deck() elif url == "empty": - self.mw.col.sched.empty_filtered_deck(self.mw.col.decks.selected()) - self.mw.reset() + self.empty_current_filtered_deck() elif url == "decks": self.mw.moveToState("deckBrowser") elif url == "review": @@ -108,27 +107,25 @@ class Overview: def _shortcutKeys(self) -> List[Tuple[str, Callable]]: return [ ("o", self.mw.onDeckConf), - ("r", self.onRebuildKey), - ("e", self.onEmptyKey), + ("r", self.rebuild_current_filtered_deck), + ("e", self.empty_current_filtered_deck), ("c", self.onCustomStudyKey), ("u", self.onUnbury), ] - def _filteredDeck(self) -> int: + def _current_deck_is_filtered(self) -> int: return self.mw.col.decks.current()["dyn"] - def onRebuildKey(self) -> None: - if self._filteredDeck(): - self.mw.col.sched.rebuild_filtered_deck(self.mw.col.decks.selected()) - self.mw.reset() + def rebuild_current_filtered_deck(self) -> None: + if self._current_deck_is_filtered(): + rebuild_filtered_deck(mw=self.mw, deck_id=self.mw.col.decks.selected()) - def onEmptyKey(self) -> None: - if self._filteredDeck(): - self.mw.col.sched.empty_filtered_deck(self.mw.col.decks.selected()) - self.mw.reset() + def empty_current_filtered_deck(self) -> None: + if self._current_deck_is_filtered(): + empty_filtered_deck(mw=self.mw, deck_id=self.mw.col.decks.selected()) def onCustomStudyKey(self) -> None: - if not self._filteredDeck(): + if not self._current_deck_is_filtered(): self.onStudyMore() def onUnbury(self) -> None: diff --git a/qt/aqt/scheduling_ops.py b/qt/aqt/scheduling_ops.py index 7468ed00a..4e8f50fbb 100644 --- a/qt/aqt/scheduling_ops.py +++ b/qt/aqt/scheduling_ops.py @@ -7,6 +7,7 @@ from typing import List, Optional, Sequence import aqt from anki.collection import CARD_TYPE_NEW, Config +from anki.decks import DeckID from anki.lang import TR from aqt import AnkiQt from aqt.main import PerformOpOptionalSuccessCallback @@ -173,3 +174,11 @@ def bury_note( lambda: mw.col.card_ids_of_note(note_id), lambda future: bury_cards(mw=mw, card_ids=future.result(), success=success), ) + + +def rebuild_filtered_deck(*, mw: AnkiQt, deck_id: DeckID) -> None: + mw.perform_op(lambda: mw.col.sched.rebuild_filtered_deck(deck_id)) + + +def empty_filtered_deck(*, mw: AnkiQt, deck_id: DeckID) -> None: + mw.perform_op(lambda: mw.col.sched.empty_filtered_deck(deck_id)) diff --git a/rslib/backend.proto b/rslib/backend.proto index 32dd788da..b0a48bdcc 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -121,8 +121,8 @@ service SchedulingService { rpc RestoreBuriedAndSuspendedCards(CardIDs) returns (OpChanges); rpc UnburyCardsInCurrentDeck(UnburyCardsInCurrentDeckIn) returns (Empty); rpc BuryOrSuspendCards(BuryOrSuspendCardsIn) returns (OpChanges); - rpc EmptyFilteredDeck(DeckID) returns (Empty); - rpc RebuildFilteredDeck(DeckID) returns (UInt32); + rpc EmptyFilteredDeck(DeckID) returns (OpChanges); + rpc RebuildFilteredDeck(DeckID) returns (OpChangesWithCount); rpc ScheduleCardsAsNew(ScheduleCardsAsNewIn) returns (OpChanges); rpc SetDueDate(SetDueDateIn) returns (OpChanges); rpc SortCards(SortCardsIn) returns (OpChangesWithCount); @@ -148,6 +148,8 @@ service DecksService { rpc RemoveDecks(DeckIDs) returns (OpChangesWithCount); rpc ReparentDecks(ReparentDecksIn) returns (OpChangesWithCount); rpc RenameDeck(RenameDeckIn) returns (OpChanges); + rpc GetOrCreateFilteredDeck(DeckID) returns (FilteredDeckForUpdate); + rpc AddOrUpdateFilteredDeck(FilteredDeckForUpdate) returns (OpChangesWithID); } service NotesService { @@ -565,6 +567,7 @@ message BackendError { Empty not_found_error = 11; Empty exists = 12; Empty deck_is_filtered = 13; + Empty filtered_deck_empty = 14; } } @@ -1525,6 +1528,12 @@ message RenameDeckIn { string new_name = 2; } +message FilteredDeckForUpdate { + int64 deck_id = 1; + string name = 2; + FilteredDeck settings = 3; +} + message SetFlagIn { repeated int64 card_ids = 1; uint32 flag = 2; diff --git a/rslib/src/backend/decks.rs b/rslib/src/backend/decks.rs index a7b73da83..090d44ab4 100644 --- a/rslib/src/backend/decks.rs +++ b/rslib/src/backend/decks.rs @@ -138,6 +138,17 @@ impl DecksService for Backend { self.with_col(|col| col.rename_deck(input.deck_id.into(), &input.new_name)) .map(Into::into) } + + fn get_or_create_filtered_deck(&self, _input: pb::DeckId) -> Result { + todo!() + } + + fn add_or_update_filtered_deck( + &self, + _input: pb::FilteredDeckForUpdate, + ) -> Result { + todo!() + } } impl From for DeckID { diff --git a/rslib/src/backend/err.rs b/rslib/src/backend/err.rs index 3cf310956..d77f8e4db 100644 --- a/rslib/src/backend/err.rs +++ b/rslib/src/backend/err.rs @@ -31,6 +31,7 @@ pub(super) fn anki_error_to_proto_error(err: AnkiError, i18n: &I18n) -> pb::Back AnkiError::SearchError(_) => V::InvalidInput(pb::Empty {}), AnkiError::TemplateSaveError { .. } => V::TemplateParse(pb::Empty {}), AnkiError::ParseNumError => V::InvalidInput(pb::Empty {}), + AnkiError::FilteredDeckEmpty => V::FilteredDeckEmpty(pb::Empty {}), }; pb::BackendError { diff --git a/rslib/src/backend/scheduler/mod.rs b/rslib/src/backend/scheduler/mod.rs index 2dae46537..f18a32c13 100644 --- a/rslib/src/backend/scheduler/mod.rs +++ b/rslib/src/backend/scheduler/mod.rs @@ -95,11 +95,11 @@ impl SchedulingService for Backend { }) } - fn empty_filtered_deck(&self, input: pb::DeckId) -> Result { + fn empty_filtered_deck(&self, input: pb::DeckId) -> Result { self.with_col(|col| col.empty_filtered_deck(input.did.into()).map(Into::into)) } - fn rebuild_filtered_deck(&self, input: pb::DeckId) -> Result { + fn rebuild_filtered_deck(&self, input: pb::DeckId) -> Result { self.with_col(|col| col.rebuild_filtered_deck(input.did.into()).map(Into::into)) } diff --git a/rslib/src/decks/mod.rs b/rslib/src/decks/mod.rs index d86a5a874..1a86c6e87 100644 --- a/rslib/src/decks/mod.rs +++ b/rslib/src/decks/mod.rs @@ -101,6 +101,14 @@ impl Deck { } } + pub(crate) fn filtered(&self) -> Result<&FilteredDeck> { + if let DeckKind::Filtered(filtered) = &self.kind { + Ok(filtered) + } else { + Err(AnkiError::invalid_input("deck not filtered")) + } + } + #[allow(dead_code)] pub(crate) fn filtered_mut(&mut self) -> Result<&mut FilteredDeck> { if let DeckKind::Filtered(filtered) = &mut self.kind { @@ -283,13 +291,14 @@ impl Collection { return Err(AnkiError::invalid_input("deck to add must have id 0")); } - self.transact(Op::AddDeck, |col| { - let usn = col.usn()?; - col.prepare_deck_for_update(deck, usn)?; - deck.set_modified(usn); - col.match_or_create_parents(deck, usn)?; - col.add_deck_undoable(deck) - }) + self.transact(Op::AddDeck, |col| col.add_deck_inner(deck, col.usn()?)) + } + + pub(crate) fn add_deck_inner(&mut self, deck: &mut Deck, usn: Usn) -> Result<()> { + self.prepare_deck_for_update(deck, usn)?; + deck.set_modified(usn); + self.match_or_create_parents(deck, usn)?; + self.add_deck_undoable(deck) } pub fn update_deck(&mut self, deck: &mut Deck) -> Result> { @@ -308,7 +317,12 @@ impl Collection { }) } - fn update_deck_inner(&mut self, deck: &mut Deck, original: Deck, usn: Usn) -> Result<()> { + pub(crate) fn update_deck_inner( + &mut self, + deck: &mut Deck, + original: Deck, + usn: Usn, + ) -> Result<()> { self.prepare_deck_for_update(deck, usn)?; deck.set_modified(usn); let name_changed = original.name != deck.name; diff --git a/rslib/src/err.rs b/rslib/src/err.rs index 9e46dae15..5b12c3657 100644 --- a/rslib/src/err.rs +++ b/rslib/src/err.rs @@ -65,6 +65,9 @@ pub enum AnkiError { #[fail(display = "Invalid search.")] SearchError(SearchErrorKind), + + #[fail(display = "Provided search(es) did not match any cards.")] + FilteredDeckEmpty, } // error helpers diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index 980c16e04..208e0e7e9 100644 --- a/rslib/src/ops.rs +++ b/rslib/src/ops.rs @@ -8,9 +8,12 @@ pub enum Op { AddDeck, AddNote, AnswerCard, + BuildFilteredDeck, Bury, ClearUnusedTags, + EmptyFilteredDeck, FindAndReplace, + RebuildFilteredDeck, RemoveDeck, RemoveNote, RemoveTag, @@ -60,6 +63,9 @@ impl Op { Op::RemoveTag => TR::ActionsRemoveTag, Op::ReparentTag => TR::ActionsRenameTag, Op::ReparentDeck => TR::ActionsRenameDeck, + Op::BuildFilteredDeck => TR::UndoBuildFilteredDeck, + Op::RebuildFilteredDeck => TR::UndoBuildFilteredDeck, + Op::EmptyFilteredDeck => TR::StudyingEmpty, }; i18n.tr(key).to_string() diff --git a/rslib/src/scheduler/answering/preview.rs b/rslib/src/scheduler/answering/preview.rs index 4dd1e9df8..b35b87bb9 100644 --- a/rslib/src/scheduler/answering/preview.rs +++ b/rslib/src/scheduler/answering/preview.rs @@ -76,7 +76,7 @@ mod test { let mut filtered_deck = Deck::new_filtered(); filtered_deck.filtered_mut()?.reschedule = false; col.add_or_update_deck(&mut filtered_deck)?; - assert_eq!(col.rebuild_filtered_deck(filtered_deck.id)?, 1); + assert_eq!(col.rebuild_filtered_deck(filtered_deck.id)?.output, 1); let next = col.get_next_card_states(c.id)?; assert!(matches!( diff --git a/rslib/src/scheduler/filtered.rs b/rslib/src/scheduler/filtered.rs deleted file mode 100644 index 3fe178df9..000000000 --- a/rslib/src/scheduler/filtered.rs +++ /dev/null @@ -1,248 +0,0 @@ -// Copyright: Ankitects Pty Ltd and contributors -// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -use crate::decks::{FilteredDeck, FilteredSearchOrder, FilteredSearchTerm}; -use crate::{ - card::{CardQueue, CardType}, - config::SchedulerVersion, - prelude::*, - search::SortMode, -}; - -impl Card { - pub(crate) fn restore_queue_from_type(&mut self) { - self.queue = match self.ctype { - CardType::Learn | CardType::Relearn => { - if self.due > 1_000_000_000 { - // unix timestamp - CardQueue::Learn - } else { - // day number - CardQueue::DayLearn - } - } - CardType::New => CardQueue::New, - CardType::Review => CardQueue::Review, - } - } - - pub(crate) fn move_into_filtered_deck(&mut self, ctx: &DeckFilterContext, position: i32) { - // filtered and v1 learning cards are excluded, so odue should be guaranteed to be zero - if self.original_due != 0 { - println!("bug: odue was set"); - return; - } - - self.original_deck_id = self.deck_id; - self.deck_id = ctx.target_deck; - - self.original_due = self.due; - - if ctx.scheduler == SchedulerVersion::V1 { - if self.ctype == CardType::Review && self.due <= ctx.today as i32 { - // review cards that are due are left in the review queue - } else { - // new + non-due go into new queue - self.queue = CardQueue::New; - } - if self.due != 0 { - self.due = position; - } - } else { - // if rescheduling is disabled, all cards go in the review queue - if !ctx.config.reschedule { - self.queue = CardQueue::Review; - } - // fixme: can we unify this with v1 scheduler in the future? - // https://anki.tenderapp.com/discussions/ankidesktop/35978-rebuilding-filtered-deck-on-experimental-v2-empties-deck-and-reschedules-to-the-year-1745 - if self.due > 0 { - self.due = position; - } - } - } - - /// Restores to the original deck and clears original_due. - /// This does not update the queue or type, so should only be used as - /// part of an operation that adjusts those separately. - pub(crate) fn remove_from_filtered_deck_before_reschedule(&mut self) { - if self.original_deck_id.0 != 0 { - self.deck_id = self.original_deck_id; - self.original_deck_id.0 = 0; - self.original_due = 0; - } - } - - pub(crate) fn original_or_current_deck_id(&self) -> DeckID { - if self.original_deck_id.0 > 0 { - self.original_deck_id - } else { - self.deck_id - } - } - - pub(crate) fn remove_from_filtered_deck_restoring_queue(&mut self, sched: SchedulerVersion) { - if self.original_deck_id.0 == 0 { - // not in a filtered deck - return; - } - - self.deck_id = self.original_deck_id; - self.original_deck_id.0 = 0; - - match sched { - SchedulerVersion::V1 => { - self.due = self.original_due; - self.queue = match self.ctype { - CardType::New => CardQueue::New, - CardType::Learn => CardQueue::New, - CardType::Review => CardQueue::Review, - // not applicable in v1, should not happen - CardType::Relearn => { - println!("did not expect relearn type in v1 for card {}", self.id); - CardQueue::New - } - }; - if self.ctype == CardType::Learn { - self.ctype = CardType::New; - } - } - SchedulerVersion::V2 => { - // original_due is cleared if card answered in filtered deck - if self.original_due > 0 { - self.due = self.original_due; - } - - if (self.queue as i8) >= 0 { - self.restore_queue_from_type(); - } - } - } - - self.original_due = 0; - } -} - -pub(crate) struct DeckFilterContext<'a> { - pub target_deck: DeckID, - pub config: &'a FilteredDeck, - pub scheduler: SchedulerVersion, - pub usn: Usn, - pub today: u32, -} - -impl Collection { - pub fn empty_filtered_deck(&mut self, did: DeckID) -> Result<()> { - self.transact_no_undo(|col| col.return_all_cards_in_filtered_deck(did)) - } - - pub(crate) fn return_all_cards_in_filtered_deck(&mut self, did: DeckID) -> Result<()> { - let cids = self.storage.all_cards_in_single_deck(did)?; - self.return_cards_to_home_deck(&cids) - } - - // Unlike the old Python code, this also marks the cards as modified. - fn return_cards_to_home_deck(&mut self, cids: &[CardID]) -> Result<()> { - let sched = self.scheduler_version(); - let usn = self.usn()?; - for cid in cids { - if let Some(mut card) = self.storage.get_card(*cid)? { - let original = card.clone(); - card.remove_from_filtered_deck_restoring_queue(sched); - self.update_card_inner(&mut card, original, usn)?; - } - } - Ok(()) - } - - // Unlike the old Python code, this also marks the cards as modified. - pub fn rebuild_filtered_deck(&mut self, did: DeckID) -> Result { - let deck = self.get_deck(did)?.ok_or(AnkiError::NotFound)?; - let config = if let DeckKind::Filtered(kind) = &deck.kind { - kind - } else { - return Err(AnkiError::invalid_input("not filtered")); - }; - let ctx = DeckFilterContext { - target_deck: did, - config, - scheduler: self.scheduler_version(), - usn: self.usn()?, - today: self.timing_today()?.days_elapsed, - }; - - self.transact_no_undo(|col| { - col.return_all_cards_in_filtered_deck(did)?; - col.build_filtered_deck(ctx) - }) - } - - fn build_filtered_deck(&mut self, ctx: DeckFilterContext) -> Result { - let start = -100_000; - let mut position = start; - for term in &ctx.config.search_terms { - position = self.move_cards_matching_term(&ctx, term, position)?; - } - - Ok((position - start) as u32) - } - - /// Move matching cards into filtered deck. - /// Returns the new starting position. - fn move_cards_matching_term( - &mut self, - ctx: &DeckFilterContext, - term: &FilteredSearchTerm, - mut position: i32, - ) -> Result { - let search = format!( - "{} -is:suspended -is:buried -deck:filtered {}", - if term.search.trim().is_empty() { - "".to_string() - } else { - format!("({})", term.search) - }, - if ctx.scheduler == SchedulerVersion::V1 { - "-is:learn" - } else { - "" - } - ); - let order = order_and_limit_for_search(term, ctx.today); - - self.search_cards_into_table(&search, SortMode::Custom(order))?; - for mut card in self.storage.all_searched_cards_in_search_order()? { - let original = card.clone(); - card.move_into_filtered_deck(ctx, position); - self.update_card_inner(&mut card, original, ctx.usn)?; - position += 1; - } - - Ok(position) - } -} - -fn order_and_limit_for_search(term: &FilteredSearchTerm, today: u32) -> String { - let temp_string; - let order = match term.order() { - FilteredSearchOrder::OldestFirst => "(select max(id) from revlog where cid=c.id)", - FilteredSearchOrder::Random => "random()", - FilteredSearchOrder::IntervalsAscending => "ivl", - FilteredSearchOrder::IntervalsDescending => "ivl desc", - FilteredSearchOrder::Lapses => "lapses desc", - FilteredSearchOrder::Added => "n.id", - FilteredSearchOrder::ReverseAdded => "n.id desc", - FilteredSearchOrder::Due => "c.due, c.ord", - FilteredSearchOrder::DuePriority => { - temp_string = format!( - " -(case when queue={rev_queue} and due <= {today} -then (ivl / cast({today}-due+0.001 as real)) else 100000+due end)", - rev_queue = CardQueue::Review as i8, - today = today - ); - &temp_string - } - }; - - format!("{} limit {}", order, term.limit) -} diff --git a/rslib/src/scheduler/filtered/card.rs b/rslib/src/scheduler/filtered/card.rs new file mode 100644 index 000000000..7b2d353b1 --- /dev/null +++ b/rslib/src/scheduler/filtered/card.rs @@ -0,0 +1,123 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::{ + card::{CardQueue, CardType}, + config::SchedulerVersion, + prelude::*, +}; + +use super::DeckFilterContext; + +impl Card { + pub(crate) fn restore_queue_from_type(&mut self) { + self.queue = match self.ctype { + CardType::Learn | CardType::Relearn => { + if self.due > 1_000_000_000 { + // unix timestamp + CardQueue::Learn + } else { + // day number + CardQueue::DayLearn + } + } + CardType::New => CardQueue::New, + CardType::Review => CardQueue::Review, + } + } + + pub(crate) fn move_into_filtered_deck(&mut self, ctx: &DeckFilterContext, position: i32) { + // filtered and v1 learning cards are excluded, so odue should be guaranteed to be zero + if self.original_due != 0 { + println!("bug: odue was set"); + return; + } + + self.original_deck_id = self.deck_id; + self.deck_id = ctx.target_deck; + + self.original_due = self.due; + + if ctx.scheduler == SchedulerVersion::V1 { + if self.ctype == CardType::Review && self.due <= ctx.today as i32 { + // review cards that are due are left in the review queue + } else { + // new + non-due go into new queue + self.queue = CardQueue::New; + } + if self.due != 0 { + self.due = position; + } + } else { + // if rescheduling is disabled, all cards go in the review queue + if !ctx.config.reschedule { + self.queue = CardQueue::Review; + } + // fixme: can we unify this with v1 scheduler in the future? + // https://anki.tenderapp.com/discussions/ankidesktop/35978-rebuilding-filtered-deck-on-experimental-v2-empties-deck-and-reschedules-to-the-year-1745 + if self.due > 0 { + self.due = position; + } + } + } + + /// Restores to the original deck and clears original_due. + /// This does not update the queue or type, so should only be used as + /// part of an operation that adjusts those separately. + pub(crate) fn remove_from_filtered_deck_before_reschedule(&mut self) { + if self.original_deck_id.0 != 0 { + self.deck_id = self.original_deck_id; + self.original_deck_id.0 = 0; + self.original_due = 0; + } + } + + pub(crate) fn original_or_current_deck_id(&self) -> DeckID { + if self.original_deck_id.0 > 0 { + self.original_deck_id + } else { + self.deck_id + } + } + + pub(crate) fn remove_from_filtered_deck_restoring_queue(&mut self, sched: SchedulerVersion) { + if self.original_deck_id.0 == 0 { + // not in a filtered deck + return; + } + + self.deck_id = self.original_deck_id; + self.original_deck_id.0 = 0; + + match sched { + SchedulerVersion::V1 => { + self.due = self.original_due; + self.queue = match self.ctype { + CardType::New => CardQueue::New, + CardType::Learn => CardQueue::New, + CardType::Review => CardQueue::Review, + // not applicable in v1, should not happen + CardType::Relearn => { + println!("did not expect relearn type in v1 for card {}", self.id); + CardQueue::New + } + }; + if self.ctype == CardType::Learn { + self.ctype = CardType::New; + } + } + SchedulerVersion::V2 => { + // original_due is cleared if card answered in filtered deck + if self.original_due > 0 { + self.due = self.original_due; + } + + if (self.queue as i8) >= 0 { + self.restore_queue_from_type(); + } + } + } + + self.original_due = 0; + } +} diff --git a/rslib/src/scheduler/filtered/mod.rs b/rslib/src/scheduler/filtered/mod.rs new file mode 100644 index 000000000..56c15bf4d --- /dev/null +++ b/rslib/src/scheduler/filtered/mod.rs @@ -0,0 +1,218 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +mod card; + +use std::convert::{TryFrom, TryInto}; + +use crate::{ + config::ConfigKey, + decks::{human_deck_name_to_native, FilteredDeck, FilteredSearchTerm}, +}; +use crate::{ + config::SchedulerVersion, prelude::*, search::SortMode, + storage::card::filtered::order_and_limit_for_search, +}; + +/// Contains the parts of a filtered deck required for modifying its settings in +/// the UI. +pub struct FilteredDeckForUpdate { + pub id: DeckID, + pub human_name: String, + pub settings: FilteredDeck, +} + +pub(crate) struct DeckFilterContext<'a> { + pub target_deck: DeckID, + pub config: &'a FilteredDeck, + pub scheduler: SchedulerVersion, + pub usn: Usn, + pub today: u32, +} + +impl Collection { + /// Get an existing filtered deck, or create a new one if `deck_id` is 0. The new deck + /// will not be added to the DB. + pub fn get_or_create_filtered_deck(&self, deck_id: DeckID) -> Result { + let deck = if deck_id.0 == 0 { + Deck { + name: self.get_next_filtered_deck_name()?, + ..Deck::new_filtered() + } + } else { + self.storage.get_deck(deck_id)?.ok_or(AnkiError::NotFound)? + }; + + deck.try_into() + } + + /// If the provided `deck_id` is 0, add provided deck to the DB, and rebuild it. If the + /// searches are invalid or do not match anything, adding is aborted. + /// If an existing deck is provided, it will be updated. Invalid searches or an empty + /// match will abort the update. + /// Returns the deck_id, which will have changed if the id was 0. + pub fn add_or_update_filtered_deck( + &mut self, + deck: FilteredDeckForUpdate, + ) -> Result> { + self.transact(Op::BuildFilteredDeck, |col| { + col.add_or_update_filtered_deck_inner(deck) + }) + } + + pub fn empty_filtered_deck(&mut self, did: DeckID) -> Result> { + self.transact(Op::EmptyFilteredDeck, |col| { + col.return_all_cards_in_filtered_deck(did) + }) + } + + // Unlike the old Python code, this also marks the cards as modified. + pub fn rebuild_filtered_deck(&mut self, did: DeckID) -> Result> { + self.transact(Op::RebuildFilteredDeck, |col| { + let deck = col.get_deck(did)?.ok_or(AnkiError::NotFound)?; + col.rebuild_filtered_deck_inner(&deck, col.usn()?) + }) + } +} + +impl Collection { + pub(crate) fn return_all_cards_in_filtered_deck(&mut self, did: DeckID) -> Result<()> { + let cids = self.storage.all_cards_in_single_deck(did)?; + self.return_cards_to_home_deck(&cids) + } + + // Unlike the old Python code, this also marks the cards as modified. + fn return_cards_to_home_deck(&mut self, cids: &[CardID]) -> Result<()> { + let sched = self.scheduler_version(); + let usn = self.usn()?; + for cid in cids { + if let Some(mut card) = self.storage.get_card(*cid)? { + let original = card.clone(); + card.remove_from_filtered_deck_restoring_queue(sched); + self.update_card_inner(&mut card, original, usn)?; + } + } + Ok(()) + } + + fn build_filtered_deck(&mut self, ctx: DeckFilterContext) -> Result { + let start = -100_000; + let mut position = start; + for term in &ctx.config.search_terms { + position = self.move_cards_matching_term(&ctx, term, position)?; + } + + Ok((position - start) as usize) + } + + /// Move matching cards into filtered deck. + /// Returns the new starting position. + fn move_cards_matching_term( + &mut self, + ctx: &DeckFilterContext, + term: &FilteredSearchTerm, + mut position: i32, + ) -> Result { + let search = format!( + "{} -is:suspended -is:buried -deck:filtered {}", + if term.search.trim().is_empty() { + "".to_string() + } else { + format!("({})", term.search) + }, + if ctx.scheduler == SchedulerVersion::V1 { + "-is:learn" + } else { + "" + } + ); + let order = order_and_limit_for_search(term, ctx.today); + + self.search_cards_into_table(&search, SortMode::Custom(order))?; + for mut card in self.storage.all_searched_cards_in_search_order()? { + let original = card.clone(); + card.move_into_filtered_deck(ctx, position); + self.update_card_inner(&mut card, original, ctx.usn)?; + position += 1; + } + + Ok(position) + } + + fn get_next_filtered_deck_name(&self) -> Result { + // fixme: + Ok("Filtered Deck 1".to_string()) + } + + fn add_or_update_filtered_deck_inner( + &mut self, + update: FilteredDeckForUpdate, + ) -> Result { + let usn = self.usn()?; + + // add or update the deck + let mut deck: Deck; + if update.id.0 == 0 { + deck = Deck::new_filtered(); + apply_update_to_filtered_deck(&mut deck, update); + self.add_deck_inner(&mut deck, usn)?; + } else { + let original = self + .storage + .get_deck(update.id)? + .ok_or(AnkiError::NotFound)?; + deck = original.clone(); + apply_update_to_filtered_deck(&mut deck, update); + self.update_deck_inner(&mut deck, original, usn)?; + } + + // rebuild it + let count = self.rebuild_filtered_deck_inner(&deck, usn)?; + + // if it failed to match any cards, we revert the changes + if count == 0 { + Err(AnkiError::FilteredDeckEmpty) + } else { + // update current deck and return id + self.set_config(ConfigKey::CurrentDeckID, &deck.id)?; + Ok(deck.id) + } + } + + pub fn rebuild_filtered_deck_inner(&mut self, deck: &Deck, usn: Usn) -> Result { + let config = deck.filtered()?; + let ctx = DeckFilterContext { + target_deck: deck.id, + config, + scheduler: self.scheduler_version(), + usn, + today: self.timing_today()?.days_elapsed, + }; + + self.return_all_cards_in_filtered_deck(deck.id)?; + self.build_filtered_deck(ctx) + } +} + +impl TryFrom for FilteredDeckForUpdate { + type Error = AnkiError; + + fn try_from(value: Deck) -> Result { + let human_name = value.human_name(); + if let DeckKind::Filtered(filtered) = value.kind { + Ok(FilteredDeckForUpdate { + id: value.id, + human_name, + settings: filtered, + }) + } else { + Err(AnkiError::invalid_input("not filtered")) + } + } +} + +fn apply_update_to_filtered_deck(deck: &mut Deck, update: FilteredDeckForUpdate) { + deck.id = update.id; + deck.name = human_deck_name_to_native(&update.human_name); + deck.kind = DeckKind::Filtered(update.settings); +} diff --git a/rslib/src/storage/card/filtered.rs b/rslib/src/storage/card/filtered.rs new file mode 100644 index 000000000..6907ab451 --- /dev/null +++ b/rslib/src/storage/card/filtered.rs @@ -0,0 +1,33 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::{ + card::CardQueue, + decks::{FilteredSearchOrder, FilteredSearchTerm}, +}; + +pub(crate) fn order_and_limit_for_search(term: &FilteredSearchTerm, today: u32) -> String { + let temp_string; + let order = match term.order() { + FilteredSearchOrder::OldestFirst => "(select max(id) from revlog where cid=c.id)", + FilteredSearchOrder::Random => "random()", + FilteredSearchOrder::IntervalsAscending => "ivl", + FilteredSearchOrder::IntervalsDescending => "ivl desc", + FilteredSearchOrder::Lapses => "lapses desc", + FilteredSearchOrder::Added => "n.id", + FilteredSearchOrder::ReverseAdded => "n.id desc", + FilteredSearchOrder::Due => "c.due, c.ord", + FilteredSearchOrder::DuePriority => { + temp_string = format!( + " +(case when queue={rev_queue} and due <= {today} +then (ivl / cast({today}-due+0.001 as real)) else 100000+due end)", + rev_queue = CardQueue::Review as i8, + today = today + ); + &temp_string + } + }; + + format!("{} limit {}", order, term.limit) +} diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index 69e63644e..cc5afcbf0 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -1,6 +1,8 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +pub(crate) mod filtered; + use crate::{ card::{Card, CardID, CardQueue, CardType}, deckconf::DeckConfID, diff --git a/rslib/src/storage/mod.rs b/rslib/src/storage/mod.rs index 6ef205599..a1ca6baa6 100644 --- a/rslib/src/storage/mod.rs +++ b/rslib/src/storage/mod.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 -mod card; +pub(crate) mod card; mod config; mod deck; mod deckconf;