mirror of
https://github.com/ankitects/anki.git
synced 2026-01-15 14:59:00 -05:00
- Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead
284 lines
9.5 KiB
Rust
284 lines
9.5 KiB
Rust
// Copyright: Ankitects Pty Ltd and contributors
|
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
|
|
pub use crate::backend_proto::{
|
|
deck_kind::Kind as DeckKind, filtered_search_term::FilteredSearchOrder, Deck as DeckProto,
|
|
DeckCommon, DeckKind as DeckKindProto, FilteredDeck, FilteredSearchTerm, NormalDeck,
|
|
};
|
|
use crate::decks::{Deck, DeckID};
|
|
use crate::{
|
|
card::{Card, CardID, CardQueue, CardType},
|
|
collection::Collection,
|
|
config::SchedulerVersion,
|
|
err::Result,
|
|
prelude::AnkiError,
|
|
search::SortMode,
|
|
timestamp::TimestampSecs,
|
|
types::Usn,
|
|
};
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
impl Deck {
|
|
pub fn new_filtered() -> Deck {
|
|
let mut filt = FilteredDeck::default();
|
|
filt.search_terms.push(FilteredSearchTerm {
|
|
search: "".into(),
|
|
limit: 100,
|
|
order: 0,
|
|
});
|
|
filt.preview_delay = 10;
|
|
filt.reschedule = true;
|
|
Deck {
|
|
id: DeckID(0),
|
|
name: "".into(),
|
|
mtime_secs: TimestampSecs(0),
|
|
usn: Usn(0),
|
|
common: DeckCommon {
|
|
study_collapsed: true,
|
|
browser_collapsed: true,
|
|
..Default::default()
|
|
},
|
|
kind: DeckKind::Filtered(filt),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn is_filtered(&self) -> bool {
|
|
matches!(self.kind, DeckKind::Filtered(_))
|
|
}
|
|
}
|
|
|
|
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(super) 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)
|
|
}
|