support undo of filtered deck build/empty
This commit is contained in:
parent
2a168adb66
commit
12597e1094
@ -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
|
||||
|
@ -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())
|
||||
|
@ -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})"
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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))
|
||||
|
@ -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;
|
||||
|
@ -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<pb::FilteredDeckForUpdate> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn add_or_update_filtered_deck(
|
||||
&self,
|
||||
_input: pb::FilteredDeckForUpdate,
|
||||
) -> Result<pb::OpChangesWithId> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<pb::DeckId> for DeckID {
|
||||
|
@ -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 {
|
||||
|
@ -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))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
|
@ -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<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)?;
|
||||
deck.set_modified(usn);
|
||||
let name_changed = original.name != deck.name;
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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!(
|
||||
|
@ -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)
|
||||
}
|
123
rslib/src/scheduler/filtered/card.rs
Normal file
123
rslib/src/scheduler/filtered/card.rs
Normal 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;
|
||||
}
|
||||
}
|
218
rslib/src/scheduler/filtered/mod.rs
Normal file
218
rslib/src/scheduler/filtered/mod.rs
Normal 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);
|
||||
}
|
33
rslib/src/storage/card/filtered.rs
Normal file
33
rslib/src/storage/card/filtered.rs
Normal 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)
|
||||
}
|
@ -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,
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user