Feat/support load balance and easy days in rescheduling (#3815)

* Feat/support load balance and easy days in rescheduling

* ./ninja fix:minilints

* apply clippy

* reuse calculate_easy_days_modifiers()

* consider LoadBalancerEnabled

* move calculate_easy_days_modifiers out of struct

* improve naming & add comments

* apply clippy

* reschedule if easy days settings are changed

* Minor simplification

* refactor to share code between load balancer and rescheduler

* intervals_and_params -> intervals_and_weights

* find_best_interval -> select_weighted_interval

* cargo clippy

* add warning about easyDaysChanged

* compare arrays directly

* Don't show warning if fsrs+rescehdule is already enabled

---------

Co-authored-by: Damien Elmes <gpg@ankiweb.net>
Co-authored-by: Jake Probst <jake.probst@gmail.com>
This commit is contained in:
Jarrett Ye 2025-02-18 14:44:00 +08:00 committed by GitHub
parent 8fc822a6e7
commit 59e143ec25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 423 additions and 105 deletions

View file

@ -293,6 +293,7 @@ deck-config-easy-days-normal = Normal
deck-config-easy-days-reduced = Reduced deck-config-easy-days-reduced = Reduced
deck-config-easy-days-minimum = Minimum deck-config-easy-days-minimum = Minimum
deck-config-easy-days-no-normal-days = At least one day should be set to '{ deck-config-easy-days-normal }'. deck-config-easy-days-no-normal-days = At least one day should be set to '{ deck-config-easy-days-normal }'.
deck-config-easy-days-change = Existing reviews will not be rescheduled unless '{ deck-config-reschedule-cards-on-change }' is enabled in the FSRS options.
## Adding/renaming ## Adding/renaming

View file

@ -215,6 +215,7 @@ impl Collection {
.unwrap_or_default(); .unwrap_or_default();
let previous_params = previous_config.map(|c| c.fsrs_params()); let previous_params = previous_config.map(|c| c.fsrs_params());
let previous_retention = previous_config.map(|c| c.inner.desired_retention); let previous_retention = previous_config.map(|c| c.inner.desired_retention);
let previous_easy_days = previous_config.map(|c| &c.inner.easy_days_percentages);
// if a selected (sub)deck, or its old config was removed, update deck to point // if a selected (sub)deck, or its old config was removed, update deck to point
// to new config // to new config
@ -242,9 +243,11 @@ impl Collection {
// if params differ, memory state needs to be recomputed // if params differ, memory state needs to be recomputed
let current_params = current_config.map(|c| c.fsrs_params()); let current_params = current_config.map(|c| c.fsrs_params());
let current_retention = current_config.map(|c| c.inner.desired_retention); let current_retention = current_config.map(|c| c.inner.desired_retention);
let current_easy_days = current_config.map(|c| &c.inner.easy_days_percentages);
if fsrs_toggled if fsrs_toggled
|| previous_params != current_params || previous_params != current_params
|| previous_retention != current_retention || previous_retention != current_retention
|| (req.fsrs_reschedule && previous_easy_days != current_easy_days)
{ {
decks_needing_memory_recompute decks_needing_memory_recompute
.entry(current_config_id) .entry(current_config_id)

View file

@ -608,7 +608,7 @@ impl Card {
/// Return a consistent seed for a given card at a given number of reps. /// Return a consistent seed for a given card at a given number of reps.
/// If for_reschedule is true, we use card.reps - 1 to match the previous /// If for_reschedule is true, we use card.reps - 1 to match the previous
/// review. /// review.
fn get_fuzz_seed(card: &Card, for_reschedule: bool) -> Option<u64> { pub(crate) fn get_fuzz_seed(card: &Card, for_reschedule: bool) -> Option<u64> {
let reps = if for_reschedule { let reps = if for_reschedule {
card.reps.saturating_sub(1) card.reps.saturating_sub(1)
} else { } else {

View file

@ -10,10 +10,12 @@ use fsrs::FSRS;
use itertools::Itertools; use itertools::Itertools;
use super::params::ignore_revlogs_before_ms_from_config; use super::params::ignore_revlogs_before_ms_from_config;
use super::rescheduler::Rescheduler;
use crate::card::CardType; use crate::card::CardType;
use crate::prelude::*; use crate::prelude::*;
use crate::revlog::RevlogEntry; use crate::revlog::RevlogEntry;
use crate::revlog::RevlogReviewKind; use crate::revlog::RevlogReviewKind;
use crate::scheduler::answering::get_fuzz_seed;
use crate::scheduler::fsrs::params::reviews_for_fsrs; use crate::scheduler::fsrs::params::reviews_for_fsrs;
use crate::scheduler::fsrs::params::Params; use crate::scheduler::fsrs::params::Params;
use crate::scheduler::states::fuzz::with_review_fuzz; use crate::scheduler::states::fuzz::with_review_fuzz;
@ -69,6 +71,10 @@ impl Collection {
} else { } else {
None None
}; };
let mut rescheduler = self
.get_config_bool(BoolKey::LoadBalancerEnabled)
.then(|| Rescheduler::new(self))
.transpose()?;
let fsrs = FSRS::new(req.as_ref().map(|w| &w.params[..]).or(Some([].as_slice())))?; let fsrs = FSRS::new(req.as_ref().map(|w| &w.params[..]).or(Some([].as_slice())))?;
let historical_retention = req.as_ref().map(|w| w.historical_retention); let historical_retention = req.as_ref().map(|w| w.historical_retention);
let items = fsrs_items_for_memory_states( let items = fsrs_items_for_memory_states(
@ -99,6 +105,10 @@ impl Collection {
if let Some(state) = &card.memory_state { if let Some(state) = &card.memory_state {
// or in (re)learning // or in (re)learning
if card.ctype == CardType::Review { if card.ctype == CardType::Review {
let deck = self
.get_deck(card.original_or_current_deck_id())?
.or_not_found(card.original_or_current_deck_id())?;
let deckconfig_id = deck.config_id().unwrap();
// reschedule it // reschedule it
let original_interval = card.interval; let original_interval = card.interval;
let interval = fsrs.next_interval( let interval = fsrs.next_interval(
@ -106,19 +116,41 @@ impl Collection {
card.desired_retention.unwrap(), card.desired_retention.unwrap(),
0, 0,
); );
card.interval = with_review_fuzz( card.interval = rescheduler
card.get_fuzz_factor(true), .as_mut()
interval, .and_then(|r| {
1, r.find_interval(
req.max_interval, interval,
); 1,
req.max_interval,
days_elapsed as u32,
deckconfig_id,
get_fuzz_seed(&card, true),
)
})
.unwrap_or_else(|| {
with_review_fuzz(
card.get_fuzz_factor(true),
interval,
1,
req.max_interval,
)
});
let due = if card.original_due != 0 { let due = if card.original_due != 0 {
&mut card.original_due &mut card.original_due
} else { } else {
&mut card.due &mut card.due
}; };
*due = (timing.days_elapsed as i32) - days_elapsed let new_due = (timing.days_elapsed as i32) - days_elapsed
+ card.interval as i32; + card.interval as i32;
if let Some(rescheduler) = &mut rescheduler {
rescheduler.update_due_cnt_per_day(
*due,
new_due,
deckconfig_id,
);
}
*due = new_due;
// Add a rescheduled revlog entry if the last entry wasn't // Add a rescheduled revlog entry if the last entry wasn't
// rescheduled // rescheduled
if !last_info.last_revlog_is_rescheduled { if !last_info.last_revlog_is_rescheduled {

View file

@ -4,6 +4,7 @@
mod error; mod error;
pub mod memory_state; pub mod memory_state;
pub mod params; pub mod params;
pub mod rescheduler;
pub mod retention; pub mod retention;
pub mod simulator; pub mod simulator;
pub mod try_collect; pub mod try_collect;

View file

@ -0,0 +1,182 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::collections::HashMap;
use chrono::Datelike;
use crate::prelude::*;
use crate::scheduler::states::fuzz::constrained_fuzz_bounds;
use crate::scheduler::states::load_balancer::build_easy_days_percentages;
use crate::scheduler::states::load_balancer::calculate_easy_days_modifiers;
use crate::scheduler::states::load_balancer::select_weighted_interval;
use crate::scheduler::states::load_balancer::EasyDay;
use crate::scheduler::states::load_balancer::LoadBalancerInterval;
pub struct Rescheduler {
today: i32,
next_day_at: TimestampSecs,
due_cnt_per_day_by_preset: HashMap<DeckConfigId, HashMap<i32, usize>>,
due_today_by_preset: HashMap<DeckConfigId, usize>,
reviewed_today_by_preset: HashMap<DeckConfigId, usize>,
easy_days_percentages_by_preset: HashMap<DeckConfigId, [EasyDay; 7]>,
}
impl Rescheduler {
pub fn new(col: &mut Collection) -> Result<Self> {
let timing = col.timing_today()?;
let deck_stats = col.storage.get_deck_due_counts()?;
let deck_map = col.storage.get_decks_map()?;
let did_to_dcid = deck_map
.values()
.filter_map(|deck| Some((deck.id, deck.config_id()?)))
.collect::<HashMap<_, _>>();
let mut due_cnt_per_day_by_preset: HashMap<DeckConfigId, HashMap<i32, usize>> =
HashMap::new();
for (did, due_date, count) in deck_stats {
let deck_config_id = did_to_dcid[&did];
due_cnt_per_day_by_preset
.entry(deck_config_id)
.or_default()
.entry(due_date)
.and_modify(|e| *e += count)
.or_insert(count);
}
let today = timing.days_elapsed as i32;
let due_today_by_preset = due_cnt_per_day_by_preset
.iter()
.map(|(deck_config_id, config_dues)| {
let due_today = config_dues
.iter()
.filter(|(&due, _)| due <= today)
.map(|(_, &count)| count)
.sum();
(*deck_config_id, due_today)
})
.collect();
let next_day_at = timing.next_day_at;
let reviewed_stats = col.storage.studied_today_by_deck(timing.next_day_at)?;
let mut reviewed_today_by_preset: HashMap<DeckConfigId, usize> = HashMap::new();
for (did, count) in reviewed_stats {
if let Some(&deck_config_id) = &did_to_dcid.get(&did) {
*reviewed_today_by_preset.entry(deck_config_id).or_default() += count;
}
}
let easy_days_percentages_by_preset =
build_easy_days_percentages(col.storage.get_deck_config_map()?)?;
Ok(Self {
today,
next_day_at,
due_cnt_per_day_by_preset,
due_today_by_preset,
reviewed_today_by_preset,
easy_days_percentages_by_preset,
})
}
pub fn update_due_cnt_per_day(
&mut self,
due_before: i32,
due_after: i32,
deck_config_id: DeckConfigId,
) {
if let Some(counts) = self.due_cnt_per_day_by_preset.get_mut(&deck_config_id) {
if let Some(count) = counts.get_mut(&due_before) {
*count -= 1;
}
*counts.entry(due_after).or_default() += 1;
}
if due_before <= self.today && due_after > self.today {
if let Some(count) = self.due_today_by_preset.get_mut(&deck_config_id) {
*count -= 1;
}
}
if due_before > self.today && due_after <= self.today {
*self.due_today_by_preset.entry(deck_config_id).or_default() += 1;
}
}
fn due_today(&self, deck_config_id: DeckConfigId) -> usize {
*self.due_today_by_preset.get(&deck_config_id).unwrap_or(&0)
}
fn reviewed_today(&self, deck_config_id: DeckConfigId) -> usize {
*self
.reviewed_today_by_preset
.get(&deck_config_id)
.unwrap_or(&0)
}
pub fn find_interval(
&self,
interval: f32,
minimum: u32,
maximum: u32,
days_elapsed: u32,
deckconfig_id: DeckConfigId,
fuzz_seed: Option<u64>,
) -> Option<u32> {
let (before_days, after_days) = constrained_fuzz_bounds(interval, minimum, maximum);
// Don't reschedule the card when it's overdue
if after_days < days_elapsed {
return None;
}
// Don't reschedule the card to the past
let before_days = before_days.max(days_elapsed);
// Generate possible intervals and their review counts
let possible_intervals: Vec<u32> = (before_days..=after_days).collect();
let review_counts: Vec<usize> = possible_intervals
.iter()
.map(|&ivl| {
if ivl > days_elapsed {
let check_due = self.today + ivl as i32 - days_elapsed as i32;
*self
.due_cnt_per_day_by_preset
.get(&deckconfig_id)
.and_then(|counts| counts.get(&check_due))
.unwrap_or(&0)
} else {
// today's workload is the sum of backlogs, cards due today and cards reviewed
// today
self.due_today(deckconfig_id) + self.reviewed_today(deckconfig_id)
}
})
.collect();
let weekdays: Vec<usize> = possible_intervals
.iter()
.map(|&ivl| {
self.next_day_at
.adding_secs(days_elapsed as i64 * -86400)
.adding_secs((ivl - 1) as i64 * 86400)
.local_datetime()
.unwrap()
.weekday()
.num_days_from_monday() as usize
})
.collect();
let easy_days_load = self.easy_days_percentages_by_preset.get(&deckconfig_id)?;
let easy_days_modifier =
calculate_easy_days_modifiers(easy_days_load, &weekdays, &review_counts);
let intervals =
possible_intervals
.iter()
.enumerate()
.map(|(interval_index, &target_interval)| LoadBalancerInterval {
target_interval,
review_count: review_counts[interval_index],
sibling_modifier: 1.0,
easy_days_modifier: easy_days_modifier[interval_index],
});
select_weighted_interval(intervals, fuzz_seed)
}
}

View file

@ -44,7 +44,7 @@ impl From<f32> for EasyDay {
} }
impl EasyDay { impl EasyDay {
fn load_modifier(&self) -> f32 { pub(crate) fn load_modifier(&self) -> f32 {
match self { match self {
// this is a non-zero value so if all days are minimum, the load balancer will // this is a non-zero value so if all days are minimum, the load balancer will
// proceed as normal // proceed as normal
@ -161,27 +161,7 @@ impl LoadBalancer {
}, },
); );
let configs = storage.get_deck_config_map()?; let configs = storage.get_deck_config_map()?;
let easy_days_percentages_by_preset = build_easy_days_percentages(configs)?;
let easy_days_percentages_by_preset = configs
.into_iter()
.map(|(dcid, conf)| {
let easy_days_percentages: [EasyDay; 7] =
if conf.inner.easy_days_percentages.is_empty() {
[EasyDay::Normal; 7]
} else {
TryInto::<[_; 7]>::try_into(conf.inner.easy_days_percentages)
.map_err(|_| {
AnkiError::from(InvalidInputError {
message: "expected 7 days".into(),
source: None,
backtrace: None,
})
})?
.map(EasyDay::from)
};
Ok((dcid, easy_days_percentages))
})
.collect::<Result<HashMap<_, [EasyDay; 7]>, AnkiError>>()?;
Ok(LoadBalancer { Ok(LoadBalancer {
days_by_preset, days_by_preset,
@ -254,87 +234,30 @@ impl LoadBalancer {
}) })
.unzip(); .unzip();
// Determine which days to schedule to with respect to Easy Day settings
// If a day is Normal, it will always be an option to schedule to
// If a day is Minimum, it will almost never be an option to schedule to
// If a day is Reduced, it will look at the amount of cards due in the fuzz
// range to determine if scheduling a card on that day would put it
// above the reduced threshold or not.
// the resulting easy_days_modifier will be a vec of 0.0s and 1.0s, to be
// used when calculating the day's weight. This turns the day on or off.
// Note that it does not actually set it to 0.0, but a small
// 0.0-ish number (see EASY_DAYS_MINIMUM_LOAD) to remove the need to
// handle a handful of zero-related corner cases.
let easy_days_load = self.easy_days_percentages_by_preset.get(&deckconfig_id)?; let easy_days_load = self.easy_days_percentages_by_preset.get(&deckconfig_id)?;
let total_review_count: usize = review_counts.iter().sum(); let easy_days_modifier =
let total_percents: f32 = weekdays calculate_easy_days_modifiers(easy_days_load, &weekdays, &review_counts);
.iter()
.map(|&weekday| easy_days_load[weekday].load_modifier())
.sum();
let easy_days_modifier = weekdays
.iter()
.zip(review_counts.iter())
.map(|(&weekday, &review_count)| {
let day = match easy_days_load[weekday] {
EasyDay::Reduced => {
const HALF: f32 = 0.5;
let other_days_review_total = (total_review_count - review_count) as f32;
let other_days_percent_total = total_percents - HALF;
let normalized_count = review_count as f32 / HALF;
let reduced_day_threshold =
other_days_review_total / other_days_percent_total;
if normalized_count > reduced_day_threshold {
EasyDay::Minimum
} else {
EasyDay::Normal
}
}
other => other,
};
day.load_modifier()
})
.collect::<Vec<_>>();
// calculate params for each day let intervals = interval_days
let intervals_and_params = interval_days
.iter() .iter()
.enumerate() .enumerate()
.map(|(interval_index, interval_day)| { .map(|(interval_index, interval_day)| {
let target_interval = interval_index as u32 + before_days; LoadBalancerInterval {
target_interval: interval_index as u32 + before_days,
review_count: review_counts[interval_index],
// if there is a sibling on this day, give it a very low weight
sibling_modifier: note_id
.and_then(|note_id| {
interval_day
.has_sibling(&note_id)
.then_some(SIBLING_PENALTY)
})
.unwrap_or(1.0),
easy_days_modifier: easy_days_modifier[interval_index],
}
});
// if there is a sibling on this day, give it a very low weight select_weighted_interval(intervals, fuzz_seed)
let sibling_multiplier = note_id
.and_then(|note_id| {
interval_day
.has_sibling(&note_id)
.then_some(SIBLING_PENALTY)
})
.unwrap_or(1.0);
let weight = match review_counts[interval_index] {
0 => 1.0, // if theres no cards due on this day, give it the full 1.0 weight
card_count => {
let card_count_weight = (1.0 / card_count as f32).powi(2);
let card_interval_weight = 1.0 / target_interval as f32;
card_count_weight
* card_interval_weight
* sibling_multiplier
* easy_days_modifier[interval_index]
}
};
(target_interval, weight)
})
.collect::<Vec<_>>();
let mut rng = StdRng::seed_from_u64(fuzz_seed?);
let weighted_intervals =
WeightedIndex::new(intervals_and_params.iter().map(|k| k.1)).ok()?;
let selected_interval_index = weighted_intervals.sample(&mut rng);
Some(intervals_and_params[selected_interval_index].0)
} }
pub fn add_card(&mut self, cid: CardId, nid: NoteId, dcid: DeckConfigId, interval: u32) { pub fn add_card(&mut self, cid: CardId, nid: NoteId, dcid: DeckConfigId, interval: u32) {
@ -354,6 +277,118 @@ impl LoadBalancer {
} }
} }
/// Build a mapping of deck config IDs to their easy days settings.
/// For each deck config, maintains an array of 7 EasyDay values representing
/// the load modifier for each day of the week.
pub(crate) fn build_easy_days_percentages(
configs: HashMap<DeckConfigId, DeckConfig>,
) -> Result<HashMap<DeckConfigId, [EasyDay; 7]>> {
configs
.into_iter()
.map(|(dcid, conf)| {
let easy_days_percentages: [EasyDay; 7] = if conf.inner.easy_days_percentages.is_empty()
{
[EasyDay::Normal; 7]
} else {
TryInto::<[_; 7]>::try_into(conf.inner.easy_days_percentages)
.map_err(|_| {
AnkiError::from(InvalidInputError {
message: "expected 7 days".into(),
source: None,
backtrace: None,
})
})?
.map(EasyDay::from)
};
Ok((dcid, easy_days_percentages))
})
.collect()
}
// Determine which days to schedule to with respect to Easy Day settings
// If a day is Normal, it will always be an option to schedule to
// If a day is Minimum, it will almost never be an option to schedule to
// If a day is Reduced, it will look at the amount of cards due in the fuzz
// range to determine if scheduling a card on that day would put it
// above the reduced threshold or not.
// the resulting easy_days_modifier will be a vec of 0.0s and 1.0s, to be
// used when calculating the day's weight. This turns the day on or off.
// Note that it does not actually set it to 0.0, but a small
// 0.0-ish number (see EASY_DAYS_MINIMUM_LOAD) to remove the need to
// handle a handful of zero-related corner cases.
pub(crate) fn calculate_easy_days_modifiers(
easy_days_load: &[EasyDay; 7],
weekdays: &[usize],
review_counts: &[usize],
) -> Vec<f32> {
let total_review_count: usize = review_counts.iter().sum();
let total_percents: f32 = weekdays
.iter()
.map(|&weekday| easy_days_load[weekday].load_modifier())
.sum();
weekdays
.iter()
.zip(review_counts.iter())
.map(|(&weekday, &review_count)| {
let day = match easy_days_load[weekday] {
EasyDay::Reduced => {
const HALF: f32 = 0.5;
let other_days_review_total = (total_review_count - review_count) as f32;
let other_days_percent_total = total_percents - HALF;
let normalized_count = review_count as f32 / HALF;
let reduced_day_threshold = other_days_review_total / other_days_percent_total;
if normalized_count > reduced_day_threshold {
EasyDay::Minimum
} else {
EasyDay::Normal
}
}
other => other,
};
day.load_modifier()
})
.collect()
}
pub struct LoadBalancerInterval {
pub target_interval: u32,
pub review_count: usize,
pub sibling_modifier: f32,
pub easy_days_modifier: f32,
}
pub fn select_weighted_interval(
intervals: impl Iterator<Item = LoadBalancerInterval>,
fuzz_seed: Option<u64>,
) -> Option<u32> {
let intervals_and_weights = intervals
.map(|interval| {
let weight = match interval.review_count {
0 => 1.0, // if theres no cards due on this day, give it the full 1.0 weight
card_count => {
let card_count_weight = (1.0 / card_count as f32).powi(2);
let card_interval_weight = 1.0 / interval.target_interval as f32;
card_count_weight
* card_interval_weight
* interval.sibling_modifier
* interval.easy_days_modifier
}
};
(interval.target_interval, weight)
})
.collect::<Vec<_>>();
let mut rng = StdRng::seed_from_u64(fuzz_seed?);
let weighted_intervals = WeightedIndex::new(intervals_and_weights.iter().map(|k| k.1)).ok()?;
let selected_interval_index = weighted_intervals.sample(&mut rng);
Some(intervals_and_weights[selected_interval_index].0)
}
fn interval_to_weekday(interval: u32, next_day_at: TimestampSecs) -> usize { fn interval_to_weekday(interval: u32, next_day_at: TimestampSecs) -> usize {
let target_datetime = next_day_at let target_datetime = next_day_at
.adding_secs((interval - 1) as i64 * 86400) .adding_secs((interval - 1) as i64 * 86400)

View file

@ -0,0 +1,14 @@
SELECT CASE
WHEN odid == 0 THEN did
ELSE odid
END AS original_did,
CASE
WHEN odid == 0 THEN due
ELSE odue
END AS true_due,
COUNT() AS COUNT
FROM cards
WHERE type = 2
AND queue != -1
GROUP BY original_did,
true_due

View file

@ -624,6 +624,15 @@ impl super::SqliteStorage {
)) ))
} }
pub(crate) fn get_deck_due_counts(&self) -> Result<Vec<(DeckId, i32, usize)>> {
self.db
.prepare(include_str!("deck_due_counts.sql"))?
.query_and_then([], |row| -> Result<_> {
Ok((DeckId(row.get(0)?), row.get(1)?, row.get(2)?))
})?
.collect()
}
pub(crate) fn congrats_info(&self, current: &Deck, today: u32) -> Result<CongratsInfo> { pub(crate) fn congrats_info(&self, current: &Deck, today: u32) -> Result<CongratsInfo> {
// NOTE: this line is obsolete in v3 as it's run on queue build, but kept to // NOTE: this line is obsolete in v3 as it's run on queue build, but kept to
// prevent errors for v1/v2 users before they upgrade // prevent errors for v1/v2 users before they upgrade

View file

@ -201,6 +201,18 @@ impl SqliteStorage {
.map_err(Into::into) .map_err(Into::into)
} }
pub(crate) fn studied_today_by_deck(
&self,
day_cutoff: TimestampSecs,
) -> Result<Vec<(DeckId, usize)>> {
let start = day_cutoff.adding_secs(-86_400).as_millis();
self.db
.prepare_cached(include_str!("studied_today_by_deck.sql"))?
.query_and_then([start.0], |row| -> Result<_> {
Ok((DeckId(row.get(0)?), row.get(1)?))
})?
.collect()
}
pub(crate) fn upgrade_revlog_to_v2(&self) -> Result<()> { pub(crate) fn upgrade_revlog_to_v2(&self) -> Result<()> {
self.db self.db
.execute_batch(include_str!("v2_upgrade.sql")) .execute_batch(include_str!("v2_upgrade.sql"))

View file

@ -0,0 +1,14 @@
SELECT CASE
WHEN c.odid == 0 THEN c.did
ELSE c.odid
END AS original_did,
COUNT(DISTINCT r.cid) AS cnt
FROM revlog AS r
JOIN cards AS c ON r.cid = c.id
WHERE r.id > ?
AND r.ease > 0
AND (
r.type < 3
OR r.factor != 0
)
GROUP BY original_did

View file

@ -13,17 +13,29 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let state: DeckOptionsState; export let state: DeckOptionsState;
export let api: Record<string, never>; export let api: Record<string, never>;
const fsrsEnabled = state.fsrs;
const reschedule = state.fsrsReschedule;
const config = state.currentConfig; const config = state.currentConfig;
const defaults = state.defaults; const defaults = state.defaults;
const prevEasyDaysPercentages = $config.easyDaysPercentages.slice();
$: if ($config.easyDaysPercentages.length !== 7) { $: if ($config.easyDaysPercentages.length !== 7) {
$config.easyDaysPercentages = defaults.easyDaysPercentages.slice(); $config.easyDaysPercentages = defaults.easyDaysPercentages.slice();
} }
$: easyDaysChanged = $config.easyDaysPercentages.some(
(value, index) => value !== prevEasyDaysPercentages[index],
);
$: noNormalDay = $config.easyDaysPercentages.some((p) => p === 1.0) $: noNormalDay = $config.easyDaysPercentages.some((p) => p === 1.0)
? "" ? ""
: tr.deckConfigEasyDaysNoNormalDays(); : tr.deckConfigEasyDaysNoNormalDays();
$: rescheduleWarning =
easyDaysChanged && !($fsrsEnabled && $reschedule)
? tr.deckConfigEasyDaysChange()
: "";
const easyDays = [ const easyDays = [
tr.deckConfigEasyDaysMonday(), tr.deckConfigEasyDaysMonday(),
tr.deckConfigEasyDaysTuesday(), tr.deckConfigEasyDaysTuesday(),
@ -81,6 +93,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<Item> <Item>
<Warning warning={noNormalDay} /> <Warning warning={noNormalDay} />
</Item> </Item>
<Item>
<Warning warning={rescheduleWarning} />
</Item>
</DynamicallySlottable> </DynamicallySlottable>
</TitledContainer> </TitledContainer>