diff --git a/CONTRIBUTORS b/CONTRIBUTORS index a874a313d..2298f51ce 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -256,6 +256,7 @@ Eltaurus jariji Francisco Esteva SelfishPig +David Grundberg <75159519+individ-divided@users.noreply.github.com> ******************** diff --git a/ftl/core/deck-config.ftl b/ftl/core/deck-config.ftl index 4930dbe0e..1cf5475e5 100644 --- a/ftl/core/deck-config.ftl +++ b/ftl/core/deck-config.ftl @@ -142,6 +142,13 @@ deck-config-new-gather-priority-tooltip-2 = can stop before all subdecks have been checked. This order is fastest in large collections, and allows you to prioritize subdecks that are closer to the top. + `Interleaved Decks`: Gathers cards by taking one card at a time + from each deck in order. This allows new cards to be distributed + evenly across decks. Cards from each subdeck are gathered in + ascending position. If the number of decks are not equal to the + daily limit of the selected deck, the last round of cards will be + drawn from a random sample of decks. + `Ascending position`: Gathers cards by ascending position (due #), which is typically the oldest-added first. @@ -197,6 +204,8 @@ deck-config-display-order-will-use-current-deck = deck-config-new-gather-priority-deck = Deck # Gather new cards ordered by deck, then ordered by random notes, ensuring all cards of the same note are grouped together. deck-config-new-gather-priority-deck-then-random-notes = Deck, then random notes +# Gather new cards by interleaving decks, taking one card at a time from each deck in order. +deck-config-new-gather-priority-interleaved-decks = Interleaved Decks # Gather new cards ordered by position number, ascending (lowest to highest). deck-config-new-gather-priority-position-lowest-first = Ascending position # Gather new cards ordered by position number, descending (highest to lowest). diff --git a/proto/anki/deck_config.proto b/proto/anki/deck_config.proto index 5ed02423e..9a9be880d 100644 --- a/proto/anki/deck_config.proto +++ b/proto/anki/deck_config.proto @@ -69,6 +69,8 @@ message DeckConfig { // Notes are randomly picked from each deck in alphabetical order. // Siblings are consecutive, provided they have the same position. NEW_CARD_GATHER_PRIORITY_DECK_THEN_RANDOM_NOTES = 5; + // One card from each deck at a time, ascending position in each deck. + NEW_CARD_GATHER_PRIORITY_INTERLEAVED_DECKS = 6; // Ascending position. // Siblings are consecutive, provided they have the same position. NEW_CARD_GATHER_PRIORITY_LOWEST_POSITION = 1; diff --git a/rslib/src/decks/limits.rs b/rslib/src/decks/limits.rs index 0866a214c..4aaa4b104 100644 --- a/rslib/src/decks/limits.rs +++ b/rslib/src/decks/limits.rs @@ -351,7 +351,7 @@ impl LimitTreeMap { .map(|node_id| self.get_node_limits(node_id)) } - fn get_root_limits(&self) -> RemainingLimits { + pub(crate) fn get_root_limits(&self) -> RemainingLimits { self.get_node_limits(self.tree.root_node_id().unwrap()) } diff --git a/rslib/src/scheduler/queue/builder/gathering.rs b/rslib/src/scheduler/queue/builder/gathering.rs index 293b50dc4..2e6ceb924 100644 --- a/rslib/src/scheduler/queue/builder/gathering.rs +++ b/rslib/src/scheduler/queue/builder/gathering.rs @@ -1,6 +1,11 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +use std::collections::HashSet; + +use rand::rng; +use rand::Rng; + use super::DueCard; use super::NewCard; use super::QueueBuilder; @@ -69,6 +74,9 @@ impl QueueBuilder { NewCardGatherPriority::DeckThenRandomNotes => { self.gather_new_cards_by_deck(col, NewCardSorting::RandomNotes(salt)) } + NewCardGatherPriority::InterleavedDecks => { + self.gather_new_cards_by_interleaved_decks(col, NewCardSorting::LowestPosition) + } NewCardGatherPriority::LowestPosition => { self.gather_new_cards_sorted(col, NewCardSorting::LowestPosition) } @@ -110,6 +118,91 @@ impl QueueBuilder { Ok(()) } + fn gather_new_cards_by_interleaved_decks( + &mut self, + col: &mut Collection, + sort: NewCardSorting, + ) -> Result<()> { + struct InterleavedDeckData { + deck_id: DeckId, + depleted: bool, + cards: std::iter::Peekable>, + } + let mut decks: Vec = vec![]; + for deck_id in col.storage.get_active_deck_ids_sorted()? { + let x: std::iter::Peekable> = col + .storage + .new_cards_in_deck(deck_id, sort)? + .into_iter() + .peekable(); + decks.push(InterleavedDeckData { + deck_id, + depleted: false, + cards: x, + }); + } + let mut rng = rng(); + let mut do_continue = true; + while do_continue { + do_continue = false; + let mut non_depleted_decks = 0; + for deck_data in &mut decks { + if self + .limits + .limit_reached(deck_data.deck_id, LimitKind::New)? + { + deck_data.depleted = true; + continue; + } + if deck_data.cards.peek().is_none() { + deck_data.depleted = true; + } else { + non_depleted_decks += 1; + } + } + let root_limit = self.limits.get_root_limits().get(LimitKind::New); + let mut sampled_deck_ids = HashSet::::new(); + let sampling = root_limit < non_depleted_decks; + if sampling { + // switch to sampling + + let mut deck_ids: Vec = vec![]; + for deck_data in &decks { + if !deck_data.depleted { + deck_ids.push(deck_data.deck_id); + } + } + for _i in 0..root_limit { + let selected_index = rng.random_range(0..deck_ids.len()); + let selected_deck_id = deck_ids[selected_index]; + sampled_deck_ids.insert(selected_deck_id); + deck_ids.swap_remove(selected_index); + } + } + for deck_data in &mut decks { + if self.limits.root_limit_reached(LimitKind::New) { + do_continue = false; + break; + } + if sampling && !sampled_deck_ids.contains(&deck_data.deck_id) { + continue; + } + if deck_data.depleted { + continue; + } + if let Some(card) = deck_data.cards.next() { + if self.add_new_card(card) { + self.limits + .decrement_deck_and_parent_limits(deck_data.deck_id, LimitKind::New)?; + do_continue = true; + } + } + } + } + + Ok(()) + } + fn gather_new_cards_sorted( &mut self, col: &mut Collection, diff --git a/rslib/src/scheduler/queue/builder/mod.rs b/rslib/src/scheduler/queue/builder/mod.rs index 064220bce..567330802 100644 --- a/rslib/src/scheduler/queue/builder/mod.rs +++ b/rslib/src/scheduler/queue/builder/mod.rs @@ -399,6 +399,19 @@ mod test { ]; assert_eq!(col.queue_as_deck_and_template(parent.id), cards); + col.set_deck_gather_order(&mut parent, NewCardGatherPriority::InterleavedDecks); + let cards = vec![ + (parent.id, 0), + (child.id, 0), + (grandchild.id, 0), + (child_2.id, 0), + (parent.id, 1), + (child.id, 1), + (grandchild.id, 1), + (child_2.id, 1), + ]; + assert_eq!(col.queue_as_deck_and_template(parent.id), cards); + // insertion order col.set_deck_gather_order(&mut parent, NewCardGatherPriority::LowestPosition); let cards = vec![ diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index 9e06edf07..84a7cd6df 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -341,6 +341,24 @@ impl super::SqliteStorage { Ok(()) } + pub(crate) fn new_cards_in_deck( + &self, + deck: DeckId, + sort: NewCardSorting, + ) -> Result> { + let mut stmt = self.db.prepare_cached(&format!( + "{} ORDER BY {}", + include_str!("new_cards.sql"), + sort.write() + ))?; + let mut rows = stmt.query(params![deck])?; + let mut names = Vec::new(); + while let Some(row) = rows.next()? { + names.push(row_to_new_card(row)?); + } + Ok(names) + } + /// Call func() for each new card in the active decks, stopping when it /// returns false or no more cards found. pub(crate) fn for_each_new_card_in_active_decks( diff --git a/ts/routes/deck-options/choices.ts b/ts/routes/deck-options/choices.ts index 6f34eae0e..14a6286f0 100644 --- a/ts/routes/deck-options/choices.ts +++ b/ts/routes/deck-options/choices.ts @@ -25,6 +25,10 @@ export function newGatherPriorityChoices(): Choice