support undo of filtered deck build/empty

This commit is contained in:
Damien Elmes 2021-03-24 12:56:06 +10:00
parent 2a168adb66
commit 12597e1094
21 changed files with 473 additions and 286 deletions

View File

@ -19,3 +19,4 @@ undo-update-card = Update Card
undo-update-deck = Update Deck undo-update-deck = Update Deck
undo-forget-card = Forget Card undo-forget-card = Forget Card
undo-set-flag = Set Flag undo-set-flag = Set Flag
undo-build-filtered-deck = Build Deck

View File

@ -433,9 +433,9 @@ class DeckManager:
"The currrently active dids." "The currrently active dids."
return self.col.get_config("activeDecks", [1]) return self.col.get_config("activeDecks", [1])
def selected(self) -> int: def selected(self) -> DeckID:
"The currently selected did." "The currently selected did."
return int(self.col.conf["curDeck"]) return DeckID(int(self.col.conf["curDeck"]))
def current(self) -> Deck: def current(self) -> Deck:
return self.get(self.selected()) return self.get(self.selected())

View File

@ -71,6 +71,7 @@ class SchedulerBase:
# fixme: used by custom study # fixme: used by custom study
def totalRevForCurrentDeck(self) -> int: def totalRevForCurrentDeck(self) -> int:
assert self.col.db
return self.col.db.scalar( return self.col.db.scalar(
f""" f"""
select count() from cards where id in ( 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 # 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) return self.col._backend.rebuild_filtered_deck(deck_id)
def empty_filtered_deck(self, deck_id: int) -> None: def empty_filtered_deck(self, deck_id: int) -> OpChanges:
self.col._backend.empty_filtered_deck(deck_id) return self.col._backend.empty_filtered_deck(deck_id)
# Suspending & burying # 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. """Set cards to be due in `days`, turning them into review cards if necessary.
`days` can be of the form '5' or '5..7' `days` can be of the form '5' or '5..7'
If `config_key` is provided, provided days will be remembered in config.""" If `config_key` is provided, provided days will be remembered in config."""
key: Optional[Config.String]
if config_key: if config_key:
key = Config.String(key=config_key) key = Config.String(key=config_key)
else: else:
key = None key = None
return self.col._backend.set_due_date( 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: def resetCards(self, ids: List[int]) -> None:
"Completely reset cards for export." "Completely reset cards for export."
sids = ids2str(ids) sids = ids2str(ids)
assert self.col.db
# we want to avoid resetting due number of existing new cards on export # we want to avoid resetting due number of existing new cards on export
nonNew = self.col.db.list( nonNew = self.col.db.list(
f"select id from cards where id in %s and (queue != {QUEUE_TYPE_NEW} or type != {CARD_TYPE_NEW})" f"select id from cards where id in %s and (queue != {QUEUE_TYPE_NEW} or type != {CARD_TYPE_NEW})"

View File

@ -50,7 +50,7 @@ class SchedulerBaseWithLegacy(SchedulerBase):
def rebuildDyn(self, did: Optional[int] = None) -> Optional[int]: def rebuildDyn(self, did: Optional[int] = None) -> Optional[int]:
did = did or self.col.decks.selected() 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: if not count:
return None return None
# and change to our new deck # and change to our new deck

View File

@ -16,6 +16,8 @@ disallow_untyped_defs = False
[mypy-anki.exporting] [mypy-anki.exporting]
disallow_untyped_defs = False disallow_untyped_defs = False
[mypy-anki.scheduler.base]
no_strict_optional = false
[mypy-win32file] [mypy-win32file]
ignore_missing_imports = True ignore_missing_imports = True

View File

@ -8,6 +8,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
import aqt import aqt
from anki.collection import OpChanges from anki.collection import OpChanges
from aqt import gui_hooks from aqt import gui_hooks
from aqt.scheduling_ops import empty_filtered_deck, rebuild_filtered_deck
from aqt.sound import av_player from aqt.sound import av_player
from aqt.toolbar import BottomBar from aqt.toolbar import BottomBar
from aqt.utils import TR, askUserDialog, openLink, shortcut, tooltip, tr from aqt.utils import TR, askUserDialog, openLink, shortcut, tooltip, tr
@ -52,12 +53,12 @@ class Overview:
self.refresh() self.refresh()
def refresh(self) -> None: def refresh(self) -> None:
self._refresh_needed = False
self.mw.col.reset() self.mw.col.reset()
self._renderPage() self._renderPage()
self._renderBottom() self._renderBottom()
self.mw.web.setFocus() self.mw.web.setFocus()
gui_hooks.overview_did_refresh(self) gui_hooks.overview_did_refresh(self)
self._refresh_needed = False
def refresh_if_needed(self) -> None: def refresh_if_needed(self) -> None:
if self._refresh_needed: if self._refresh_needed:
@ -88,11 +89,9 @@ class Overview:
elif url == "cram": elif url == "cram":
aqt.dialogs.open("DynDeckConfDialog", self.mw) aqt.dialogs.open("DynDeckConfDialog", self.mw)
elif url == "refresh": elif url == "refresh":
self.mw.col.sched.rebuild_filtered_deck(self.mw.col.decks.selected()) self.rebuild_current_filtered_deck()
self.mw.reset()
elif url == "empty": elif url == "empty":
self.mw.col.sched.empty_filtered_deck(self.mw.col.decks.selected()) self.empty_current_filtered_deck()
self.mw.reset()
elif url == "decks": elif url == "decks":
self.mw.moveToState("deckBrowser") self.mw.moveToState("deckBrowser")
elif url == "review": elif url == "review":
@ -108,27 +107,25 @@ class Overview:
def _shortcutKeys(self) -> List[Tuple[str, Callable]]: def _shortcutKeys(self) -> List[Tuple[str, Callable]]:
return [ return [
("o", self.mw.onDeckConf), ("o", self.mw.onDeckConf),
("r", self.onRebuildKey), ("r", self.rebuild_current_filtered_deck),
("e", self.onEmptyKey), ("e", self.empty_current_filtered_deck),
("c", self.onCustomStudyKey), ("c", self.onCustomStudyKey),
("u", self.onUnbury), ("u", self.onUnbury),
] ]
def _filteredDeck(self) -> int: def _current_deck_is_filtered(self) -> int:
return self.mw.col.decks.current()["dyn"] return self.mw.col.decks.current()["dyn"]
def onRebuildKey(self) -> None: def rebuild_current_filtered_deck(self) -> None:
if self._filteredDeck(): if self._current_deck_is_filtered():
self.mw.col.sched.rebuild_filtered_deck(self.mw.col.decks.selected()) rebuild_filtered_deck(mw=self.mw, deck_id=self.mw.col.decks.selected())
self.mw.reset()
def onEmptyKey(self) -> None: def empty_current_filtered_deck(self) -> None:
if self._filteredDeck(): if self._current_deck_is_filtered():
self.mw.col.sched.empty_filtered_deck(self.mw.col.decks.selected()) empty_filtered_deck(mw=self.mw, deck_id=self.mw.col.decks.selected())
self.mw.reset()
def onCustomStudyKey(self) -> None: def onCustomStudyKey(self) -> None:
if not self._filteredDeck(): if not self._current_deck_is_filtered():
self.onStudyMore() self.onStudyMore()
def onUnbury(self) -> None: def onUnbury(self) -> None:

View File

@ -7,6 +7,7 @@ from typing import List, Optional, Sequence
import aqt import aqt
from anki.collection import CARD_TYPE_NEW, Config from anki.collection import CARD_TYPE_NEW, Config
from anki.decks import DeckID
from anki.lang import TR from anki.lang import TR
from aqt import AnkiQt from aqt import AnkiQt
from aqt.main import PerformOpOptionalSuccessCallback from aqt.main import PerformOpOptionalSuccessCallback
@ -173,3 +174,11 @@ def bury_note(
lambda: mw.col.card_ids_of_note(note_id), lambda: mw.col.card_ids_of_note(note_id),
lambda future: bury_cards(mw=mw, card_ids=future.result(), success=success), 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))

View File

@ -121,8 +121,8 @@ service SchedulingService {
rpc RestoreBuriedAndSuspendedCards(CardIDs) returns (OpChanges); rpc RestoreBuriedAndSuspendedCards(CardIDs) returns (OpChanges);
rpc UnburyCardsInCurrentDeck(UnburyCardsInCurrentDeckIn) returns (Empty); rpc UnburyCardsInCurrentDeck(UnburyCardsInCurrentDeckIn) returns (Empty);
rpc BuryOrSuspendCards(BuryOrSuspendCardsIn) returns (OpChanges); rpc BuryOrSuspendCards(BuryOrSuspendCardsIn) returns (OpChanges);
rpc EmptyFilteredDeck(DeckID) returns (Empty); rpc EmptyFilteredDeck(DeckID) returns (OpChanges);
rpc RebuildFilteredDeck(DeckID) returns (UInt32); rpc RebuildFilteredDeck(DeckID) returns (OpChangesWithCount);
rpc ScheduleCardsAsNew(ScheduleCardsAsNewIn) returns (OpChanges); rpc ScheduleCardsAsNew(ScheduleCardsAsNewIn) returns (OpChanges);
rpc SetDueDate(SetDueDateIn) returns (OpChanges); rpc SetDueDate(SetDueDateIn) returns (OpChanges);
rpc SortCards(SortCardsIn) returns (OpChangesWithCount); rpc SortCards(SortCardsIn) returns (OpChangesWithCount);
@ -148,6 +148,8 @@ service DecksService {
rpc RemoveDecks(DeckIDs) returns (OpChangesWithCount); rpc RemoveDecks(DeckIDs) returns (OpChangesWithCount);
rpc ReparentDecks(ReparentDecksIn) returns (OpChangesWithCount); rpc ReparentDecks(ReparentDecksIn) returns (OpChangesWithCount);
rpc RenameDeck(RenameDeckIn) returns (OpChanges); rpc RenameDeck(RenameDeckIn) returns (OpChanges);
rpc GetOrCreateFilteredDeck(DeckID) returns (FilteredDeckForUpdate);
rpc AddOrUpdateFilteredDeck(FilteredDeckForUpdate) returns (OpChangesWithID);
} }
service NotesService { service NotesService {
@ -565,6 +567,7 @@ message BackendError {
Empty not_found_error = 11; Empty not_found_error = 11;
Empty exists = 12; Empty exists = 12;
Empty deck_is_filtered = 13; Empty deck_is_filtered = 13;
Empty filtered_deck_empty = 14;
} }
} }
@ -1525,6 +1528,12 @@ message RenameDeckIn {
string new_name = 2; string new_name = 2;
} }
message FilteredDeckForUpdate {
int64 deck_id = 1;
string name = 2;
FilteredDeck settings = 3;
}
message SetFlagIn { message SetFlagIn {
repeated int64 card_ids = 1; repeated int64 card_ids = 1;
uint32 flag = 2; uint32 flag = 2;

View File

@ -138,6 +138,17 @@ impl DecksService for Backend {
self.with_col(|col| col.rename_deck(input.deck_id.into(), &input.new_name)) self.with_col(|col| col.rename_deck(input.deck_id.into(), &input.new_name))
.map(Into::into) .map(Into::into)
} }
fn get_or_create_filtered_deck(&self, _input: pb::DeckId) -> Result<pb::FilteredDeckForUpdate> {
todo!()
}
fn add_or_update_filtered_deck(
&self,
_input: pb::FilteredDeckForUpdate,
) -> Result<pb::OpChangesWithId> {
todo!()
}
} }
impl From<pb::DeckId> for DeckID { impl From<pb::DeckId> for DeckID {

View File

@ -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::SearchError(_) => V::InvalidInput(pb::Empty {}),
AnkiError::TemplateSaveError { .. } => V::TemplateParse(pb::Empty {}), AnkiError::TemplateSaveError { .. } => V::TemplateParse(pb::Empty {}),
AnkiError::ParseNumError => V::InvalidInput(pb::Empty {}), AnkiError::ParseNumError => V::InvalidInput(pb::Empty {}),
AnkiError::FilteredDeckEmpty => V::FilteredDeckEmpty(pb::Empty {}),
}; };
pb::BackendError { pb::BackendError {

View File

@ -95,11 +95,11 @@ impl SchedulingService for Backend {
}) })
} }
fn empty_filtered_deck(&self, input: pb::DeckId) -> Result<pb::Empty> { fn empty_filtered_deck(&self, input: pb::DeckId) -> Result<pb::OpChanges> {
self.with_col(|col| col.empty_filtered_deck(input.did.into()).map(Into::into)) self.with_col(|col| col.empty_filtered_deck(input.did.into()).map(Into::into))
} }
fn rebuild_filtered_deck(&self, input: pb::DeckId) -> Result<pb::UInt32> { fn rebuild_filtered_deck(&self, input: pb::DeckId) -> Result<pb::OpChangesWithCount> {
self.with_col(|col| col.rebuild_filtered_deck(input.did.into()).map(Into::into)) self.with_col(|col| col.rebuild_filtered_deck(input.did.into()).map(Into::into))
} }

View File

@ -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)] #[allow(dead_code)]
pub(crate) fn filtered_mut(&mut self) -> Result<&mut FilteredDeck> { pub(crate) fn filtered_mut(&mut self) -> Result<&mut FilteredDeck> {
if let DeckKind::Filtered(filtered) = &mut self.kind { 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")); return Err(AnkiError::invalid_input("deck to add must have id 0"));
} }
self.transact(Op::AddDeck, |col| { self.transact(Op::AddDeck, |col| col.add_deck_inner(deck, col.usn()?))
let usn = col.usn()?; }
col.prepare_deck_for_update(deck, 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); deck.set_modified(usn);
col.match_or_create_parents(deck, usn)?; self.match_or_create_parents(deck, usn)?;
col.add_deck_undoable(deck) self.add_deck_undoable(deck)
})
} }
pub fn update_deck(&mut self, deck: &mut Deck) -> Result<OpOutput<()>> { pub fn update_deck(&mut self, deck: &mut Deck) -> Result<OpOutput<()>> {
@ -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)?; self.prepare_deck_for_update(deck, usn)?;
deck.set_modified(usn); deck.set_modified(usn);
let name_changed = original.name != deck.name; let name_changed = original.name != deck.name;

View File

@ -65,6 +65,9 @@ pub enum AnkiError {
#[fail(display = "Invalid search.")] #[fail(display = "Invalid search.")]
SearchError(SearchErrorKind), SearchError(SearchErrorKind),
#[fail(display = "Provided search(es) did not match any cards.")]
FilteredDeckEmpty,
} }
// error helpers // error helpers

View File

@ -8,9 +8,12 @@ pub enum Op {
AddDeck, AddDeck,
AddNote, AddNote,
AnswerCard, AnswerCard,
BuildFilteredDeck,
Bury, Bury,
ClearUnusedTags, ClearUnusedTags,
EmptyFilteredDeck,
FindAndReplace, FindAndReplace,
RebuildFilteredDeck,
RemoveDeck, RemoveDeck,
RemoveNote, RemoveNote,
RemoveTag, RemoveTag,
@ -60,6 +63,9 @@ impl Op {
Op::RemoveTag => TR::ActionsRemoveTag, Op::RemoveTag => TR::ActionsRemoveTag,
Op::ReparentTag => TR::ActionsRenameTag, Op::ReparentTag => TR::ActionsRenameTag,
Op::ReparentDeck => TR::ActionsRenameDeck, Op::ReparentDeck => TR::ActionsRenameDeck,
Op::BuildFilteredDeck => TR::UndoBuildFilteredDeck,
Op::RebuildFilteredDeck => TR::UndoBuildFilteredDeck,
Op::EmptyFilteredDeck => TR::StudyingEmpty,
}; };
i18n.tr(key).to_string() i18n.tr(key).to_string()

View File

@ -76,7 +76,7 @@ mod test {
let mut filtered_deck = Deck::new_filtered(); let mut filtered_deck = Deck::new_filtered();
filtered_deck.filtered_mut()?.reschedule = false; filtered_deck.filtered_mut()?.reschedule = false;
col.add_or_update_deck(&mut filtered_deck)?; 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)?; let next = col.get_next_card_states(c.id)?;
assert!(matches!( assert!(matches!(

View File

@ -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<u32> {
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<u32> {
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<i32> {
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)
}

View File

@ -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;
}
}

View File

@ -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<FilteredDeckForUpdate> {
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<OpOutput<DeckID>> {
self.transact(Op::BuildFilteredDeck, |col| {
col.add_or_update_filtered_deck_inner(deck)
})
}
pub fn empty_filtered_deck(&mut self, did: DeckID) -> Result<OpOutput<()>> {
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<OpOutput<usize>> {
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<usize> {
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<i32> {
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<String> {
// fixme:
Ok("Filtered Deck 1".to_string())
}
fn add_or_update_filtered_deck_inner(
&mut self,
update: FilteredDeckForUpdate,
) -> Result<DeckID> {
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<usize> {
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<Deck> for FilteredDeckForUpdate {
type Error = AnkiError;
fn try_from(value: Deck) -> Result<Self, Self::Error> {
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);
}

View File

@ -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)
}

View File

@ -1,6 +1,8 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
pub(crate) mod filtered;
use crate::{ use crate::{
card::{Card, CardID, CardQueue, CardType}, card::{Card, CardID, CardQueue, CardType},
deckconf::DeckConfID, deckconf::DeckConfID,

View File

@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
mod card; pub(crate) mod card;
mod config; mod config;
mod deck; mod deck;
mod deckconf; mod deckconf;