Merge branch 'main' into cached-workload

This commit is contained in:
Damien Elmes 2025-07-28 18:57:33 +10:00 committed by GitHub
commit 54bf0bd162
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 878 additions and 268 deletions

View file

@ -1 +1 @@
25.07.3 25.07.5

1
Cargo.lock generated
View file

@ -131,6 +131,7 @@ dependencies = [
"prost-reflect", "prost-reflect",
"pulldown-cmark 0.13.0", "pulldown-cmark 0.13.0",
"rand 0.9.1", "rand 0.9.1",
"rayon",
"regex", "regex",
"reqwest 0.12.20", "reqwest 0.12.20",
"rusqlite", "rusqlite",

View file

@ -111,6 +111,7 @@ prost-types = "0.13"
pulldown-cmark = "0.13.0" pulldown-cmark = "0.13.0"
pyo3 = { version = "0.25.1", features = ["extension-module", "abi3", "abi3-py39"] } pyo3 = { version = "0.25.1", features = ["extension-module", "abi3", "abi3-py39"] }
rand = "0.9.1" rand = "0.9.1"
rayon = "1.10.0"
regex = "1.11.1" regex = "1.11.1"
reqwest = { version = "0.12.20", default-features = false, features = ["json", "socks", "stream", "multipart"] } reqwest = { version = "0.12.20", default-features = false, features = ["json", "socks", "stream", "multipart"] }
rusqlite = { version = "0.36.0", features = ["trace", "functions", "collation", "bundled"] } rusqlite = { version = "0.36.0", features = ["trace", "functions", "collation", "bundled"] }

@ -1 +1 @@
Subproject commit b90ef6f03c251eb336029ac7c5f551200d41273f Subproject commit 939298f7c461407951988f362b1a08b451336a1e

View file

@ -505,7 +505,9 @@ deck-config-desired-retention-below-optimal = Your desired retention is below op
# Description of the y axis in the FSRS simulation # Description of the y axis in the FSRS simulation
# diagram (Deck options -> FSRS) showing the total number of # diagram (Deck options -> FSRS) showing the total number of
# cards that can be recalled or retrieved on a specific date. # cards that can be recalled or retrieved on a specific date.
deck-config-fsrs-simulator-experimental = FSRS simulator (experimental) deck-config-fsrs-simulator-experimental = FSRS Simulator (Experimental)
deck-config-fsrs-simulate-desired-retention-experimental = FSRS Desired Retention Simulator (Experimental)
deck-config-fsrs-desired-retention-help-me-decide-experimental = Help Me Decide (Experimental)
deck-config-additional-new-cards-to-simulate = Additional new cards to simulate deck-config-additional-new-cards-to-simulate = Additional new cards to simulate
deck-config-simulate = Simulate deck-config-simulate = Simulate
deck-config-clear-last-simulate = Clear Last Simulation deck-config-clear-last-simulate = Clear Last Simulation
@ -515,10 +517,14 @@ deck-config-smooth-graph = Smooth graph
deck-config-suspend-leeches = Suspend leeches deck-config-suspend-leeches = Suspend leeches
deck-config-save-options-to-preset = Save Changes to Preset deck-config-save-options-to-preset = Save Changes to Preset
deck-config-save-options-to-preset-confirm = Overwrite the options in your current preset with the options that are currently set in the simulator? deck-config-save-options-to-preset-confirm = Overwrite the options in your current preset with the options that are currently set in the simulator?
deck-config-plotted-on-x-axis = (Plotted on the X-axis)
# Radio button in the FSRS simulation diagram (Deck options -> FSRS) selecting # Radio button in the FSRS simulation diagram (Deck options -> FSRS) selecting
# to show the total number of cards that can be recalled or retrieved on a # to show the total number of cards that can be recalled or retrieved on a
# specific date. # specific date.
deck-config-fsrs-simulator-radio-memorized = Memorized deck-config-fsrs-simulator-radio-memorized = Memorized
deck-config-fsrs-simulator-radio-ratio = Time / Memorized Ratio
# $time here is pre-formatted e.g. "10 Seconds"
deck-config-fsrs-simulator-ratio-tooltip = { $time } per memorized card
## Messages related to the FSRS schedulers health check. The health check determines whether the correlation between FSRS predictions and your memory is good or bad. It can be optionally triggered as part of the "Optimize" function. ## Messages related to the FSRS schedulers health check. The health check determines whether the correlation between FSRS predictions and your memory is good or bad. It can be optionally triggered as part of the "Optimize" function.

@ -1 +1 @@
Subproject commit 9aa63c335c61b30421d39cf43fd8e3975179059c Subproject commit bc2da83c77749d96f3df8144f00c87d68dd2187a

View file

@ -217,6 +217,8 @@ message DeckConfigsForUpdate {
bool review_today_active = 5; bool review_today_active = 5;
// Whether new_today applies to today or a past day. // Whether new_today applies to today or a past day.
bool new_today_active = 6; bool new_today_active = 6;
// Deck-specific desired retention override
optional float desired_retention = 7;
} }
string name = 1; string name = 1;
int64 config_id = 2; int64 config_id = 2;

View file

@ -83,6 +83,8 @@ message Deck {
optional uint32 new_limit = 7; optional uint32 new_limit = 7;
DayLimit review_limit_today = 8; DayLimit review_limit_today = 8;
DayLimit new_limit_today = 9; DayLimit new_limit_today = 9;
// Deck-specific desired retention override
optional float desired_retention = 10;
reserved 12 to 15; reserved 12 to 15;
} }

View file

@ -55,6 +55,8 @@ service SchedulerService {
returns (ComputeOptimalRetentionResponse); returns (ComputeOptimalRetentionResponse);
rpc SimulateFsrsReview(SimulateFsrsReviewRequest) rpc SimulateFsrsReview(SimulateFsrsReviewRequest)
returns (SimulateFsrsReviewResponse); returns (SimulateFsrsReviewResponse);
rpc SimulateFsrsWorkload(SimulateFsrsReviewRequest)
returns (SimulateFsrsWorkloadResponse);
rpc EvaluateParams(EvaluateParamsRequest) returns (EvaluateParamsResponse); rpc EvaluateParams(EvaluateParamsRequest) returns (EvaluateParamsResponse);
rpc EvaluateParamsLegacy(EvaluateParamsLegacyRequest) rpc EvaluateParamsLegacy(EvaluateParamsLegacyRequest)
returns (EvaluateParamsResponse); returns (EvaluateParamsResponse);
@ -414,6 +416,12 @@ message SimulateFsrsReviewResponse {
repeated float daily_time_cost = 4; repeated float daily_time_cost = 4;
} }
message SimulateFsrsWorkloadResponse {
map<uint32, float> cost = 1;
map<uint32, float> memorized = 2;
map<uint32, uint32> review_count = 3;
}
message ComputeOptimalRetentionResponse { message ComputeOptimalRetentionResponse {
float optimal_retention = 1; float optimal_retention = 1;
} }

View file

@ -133,6 +133,7 @@ class Card(DeprecatedNamesMixin):
memory_state=self.memory_state, memory_state=self.memory_state,
desired_retention=self.desired_retention, desired_retention=self.desired_retention,
decay=self.decay, decay=self.decay,
last_review_time_secs=self.last_review_time,
) )
@deprecated(info="please use col.update_card()") @deprecated(info="please use col.update_card()")

View file

@ -73,7 +73,7 @@ langs = sorted(
("ଓଡ଼ିଆ", "or_OR"), ("ଓଡ଼ିଆ", "or_OR"),
("Filipino", "tl"), ("Filipino", "tl"),
("ئۇيغۇر", "ug"), ("ئۇيغۇر", "ug"),
("Oʻzbek", "uz_UZ"), ("Oʻzbekcha", "uz_UZ"),
] ]
) )

View file

@ -51,6 +51,7 @@ class CardInfoDialog(QDialog):
def _setup_ui(self, card_id: CardId | None) -> None: def _setup_ui(self, card_id: CardId | None) -> None:
self.mw.garbage_collect_on_dialog_finish(self) self.mw.garbage_collect_on_dialog_finish(self)
self.setMinimumSize(400, 300)
disable_help_button(self) disable_help_button(self)
restoreGeom(self, self.GEOMETRY_KEY, default_size=(800, 800)) restoreGeom(self, self.GEOMETRY_KEY, default_size=(800, 800))
add_close_shortcut(self) add_close_shortcut(self)

View file

@ -654,6 +654,7 @@ exposed_backend_list = [
"evaluate_params_legacy", "evaluate_params_legacy",
"get_optimal_retention_parameters", "get_optimal_retention_parameters",
"simulate_fsrs_review", "simulate_fsrs_review",
"simulate_fsrs_workload",
# DeckConfigService # DeckConfigService
"get_ignored_before_count", "get_ignored_before_count",
"get_retention_workload", "get_retention_workload",

View file

@ -633,7 +633,7 @@ class QtAudioInputRecorder(Recorder):
from PyQt6.QtMultimedia import QAudioFormat, QAudioSource # type: ignore from PyQt6.QtMultimedia import QAudioFormat, QAudioSource # type: ignore
format = QAudioFormat() format = QAudioFormat()
format.setChannelCount(1) format.setChannelCount(2)
format.setSampleRate(44100) format.setSampleRate(44100)
format.setSampleFormat(QAudioFormat.SampleFormat.Int16) format.setSampleFormat(QAudioFormat.SampleFormat.Int16)

View file

@ -118,7 +118,7 @@ def sync_collection(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
if out.new_endpoint: if out.new_endpoint:
mw.pm.set_current_sync_url(out.new_endpoint) mw.pm.set_current_sync_url(out.new_endpoint)
if out.server_message: if out.server_message:
showText(out.server_message) showText(out.server_message, parent=mw)
if out.required == out.NO_CHANGES: if out.required == out.NO_CHANGES:
tooltip(parent=mw, msg=tr.sync_collection_complete()) tooltip(parent=mw, msg=tr.sync_collection_complete())
# all done; track media progress # all done; track media progress

View file

@ -38,19 +38,19 @@ cp ../pyproject.toml "$APP_LAUNCHER/Contents/Resources/"
cp ../../../.python-version "$APP_LAUNCHER/Contents/Resources/" cp ../../../.python-version "$APP_LAUNCHER/Contents/Resources/"
cp ../versions.py "$APP_LAUNCHER/Contents/Resources/" cp ../versions.py "$APP_LAUNCHER/Contents/Resources/"
# Codesign # Codesign/bundle
for i in "$APP_LAUNCHER/Contents/MacOS/uv" "$APP_LAUNCHER/Contents/MacOS/launcher" "$APP_LAUNCHER"; do
codesign --force -vvvv -o runtime -s "Developer ID Application:" \
--entitlements entitlements.python.xml \
"$i"
done
# Check
codesign -vvv "$APP_LAUNCHER"
spctl -a "$APP_LAUNCHER"
# Notarize and bundle (skip if NODMG is set)
if [ -z "$NODMG" ]; then if [ -z "$NODMG" ]; then
for i in "$APP_LAUNCHER/Contents/MacOS/uv" "$APP_LAUNCHER/Contents/MacOS/launcher" "$APP_LAUNCHER"; do
codesign --force -vvvv -o runtime -s "Developer ID Application:" \
--entitlements entitlements.python.xml \
"$i"
done
# Check
codesign -vvv "$APP_LAUNCHER"
spctl -a "$APP_LAUNCHER"
# Notarize and build dmg
./notarize.sh "$OUTPUT_DIR" ./notarize.sh "$OUTPUT_DIR"
./dmg/build.sh "$OUTPUT_DIR" ./dmg/build.sh "$OUTPUT_DIR"
fi fi

View file

@ -11,7 +11,6 @@ use std::time::SystemTime;
use std::time::UNIX_EPOCH; use std::time::UNIX_EPOCH;
use anki_io::copy_file; use anki_io::copy_file;
use anki_io::copy_if_newer;
use anki_io::create_dir_all; use anki_io::create_dir_all;
use anki_io::modified_time; use anki_io::modified_time;
use anki_io::read_file; use anki_io::read_file;
@ -50,6 +49,7 @@ struct State {
pyproject_modified_by_user: bool, pyproject_modified_by_user: bool,
previous_version: Option<String>, previous_version: Option<String>,
resources_dir: std::path::PathBuf, resources_dir: std::path::PathBuf,
venv_folder: std::path::PathBuf,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -111,6 +111,7 @@ fn run() -> Result<()> {
pyproject_modified_by_user: false, // calculated later pyproject_modified_by_user: false, // calculated later
previous_version: None, previous_version: None,
resources_dir, resources_dir,
venv_folder: uv_install_root.join(".venv"),
}; };
// Check for uninstall request from Windows uninstaller // Check for uninstall request from Windows uninstaller
@ -120,15 +121,11 @@ fn run() -> Result<()> {
return Ok(()); return Ok(());
} }
// Create install directory and copy project files in // Create install directory
create_dir_all(&state.uv_install_root)?; create_dir_all(&state.uv_install_root)?;
copy_if_newer(&state.dist_pyproject_path, &state.user_pyproject_path)?;
copy_if_newer(
&state.dist_python_version_path,
&state.user_python_version_path,
)?;
let launcher_requested = state.launcher_trigger_file.exists(); let launcher_requested =
state.launcher_trigger_file.exists() || !state.user_pyproject_path.exists();
// Calculate whether user has custom edits that need syncing // Calculate whether user has custom edits that need syncing
let pyproject_time = file_timestamp_secs(&state.user_pyproject_path); let pyproject_time = file_timestamp_secs(&state.user_pyproject_path);
@ -158,7 +155,7 @@ fn run() -> Result<()> {
check_versions(&mut state); check_versions(&mut state);
let first_run = !state.uv_install_root.join(".venv").exists(); let first_run = !state.venv_folder.exists();
if first_run { if first_run {
handle_version_install_or_update(&state, MainMenuChoice::Latest)?; handle_version_install_or_update(&state, MainMenuChoice::Latest)?;
} else { } else {
@ -166,7 +163,7 @@ fn run() -> Result<()> {
} }
// Write marker file to indicate we've completed the sync process // Write marker file to indicate we've completed the sync process
write_sync_marker(&state.sync_complete_marker)?; write_sync_marker(&state)?;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
@ -192,13 +189,15 @@ fn run() -> Result<()> {
Ok(()) Ok(())
} }
fn extract_aqt_version( fn extract_aqt_version(state: &State) -> Option<String> {
uv_path: &std::path::Path, // Check if .venv exists first
uv_install_root: &std::path::Path, if !state.venv_folder.exists() {
) -> Option<String> { return None;
let output = Command::new(uv_path) }
.current_dir(uv_install_root)
.env("VIRTUAL_ENV", uv_install_root.join(".venv")) let output = Command::new(&state.uv_path)
.current_dir(&state.uv_install_root)
.env("VIRTUAL_ENV", &state.venv_folder)
.args(["pip", "show", "aqt"]) .args(["pip", "show", "aqt"])
.output() .output()
.ok()?; .ok()?;
@ -223,7 +222,7 @@ fn check_versions(state: &mut State) {
} }
// Determine current version by invoking uv pip show aqt // Determine current version by invoking uv pip show aqt
match extract_aqt_version(&state.uv_path, &state.uv_install_root) { match extract_aqt_version(state) {
Some(version) => { Some(version) => {
state.current_version = Some(version); state.current_version = Some(version);
} }
@ -248,12 +247,12 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re
update_pyproject_for_version(choice.clone(), state)?; update_pyproject_for_version(choice.clone(), state)?;
// Extract current version before syncing (but don't write to file yet) // Extract current version before syncing (but don't write to file yet)
let previous_version_to_save = extract_aqt_version(&state.uv_path, &state.uv_install_root); let previous_version_to_save = extract_aqt_version(state);
// Remove sync marker before attempting sync // Remove sync marker before attempting sync
let _ = remove_file(&state.sync_complete_marker); let _ = remove_file(&state.sync_complete_marker);
println!("\x1B[1mUpdating Anki...\x1B[0m\n"); println!("Updating Anki...\n");
let python_version_trimmed = if state.user_python_version_path.exists() { let python_version_trimmed = if state.user_python_version_path.exists() {
let python_version = read_file(&state.user_python_version_path)?; let python_version = read_file(&state.user_python_version_path)?;
@ -264,6 +263,11 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re
None None
}; };
let have_venv = state.venv_folder.exists();
if cfg!(target_os = "macos") && !have_developer_tools() && !have_venv {
println!("If you see a pop-up about 'install_name_tool', you can cancel it, and ignore the warning below.\n");
}
// Prepare to sync the venv // Prepare to sync the venv
let mut command = Command::new(&state.uv_path); let mut command = Command::new(&state.uv_path);
command.current_dir(&state.uv_install_root); command.current_dir(&state.uv_install_root);
@ -275,9 +279,27 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re
} }
} }
// remove CONDA_PREFIX/bin from PATH to avoid conda interference
#[cfg(target_os = "macos")]
if let Ok(conda_prefix) = std::env::var("CONDA_PREFIX") {
if let Ok(current_path) = std::env::var("PATH") {
let conda_bin = format!("{conda_prefix}/bin");
let filtered_paths: Vec<&str> = current_path
.split(':')
.filter(|&path| path != conda_bin)
.collect();
let new_path = filtered_paths.join(":");
command.env("PATH", new_path);
}
}
command command
.env("UV_CACHE_DIR", &state.uv_cache_dir) .env("UV_CACHE_DIR", &state.uv_cache_dir)
.env("UV_PYTHON_INSTALL_DIR", &state.uv_python_install_dir) .env("UV_PYTHON_INSTALL_DIR", &state.uv_python_install_dir)
.env(
"UV_HTTP_TIMEOUT",
std::env::var("UV_HTTP_TIMEOUT").unwrap_or_else(|_| "180".to_string()),
)
.args(["sync", "--upgrade", "--managed-python", "--no-config"]); .args(["sync", "--upgrade", "--managed-python", "--no-config"]);
// Add python version if .python-version file exists // Add python version if .python-version file exists
@ -293,7 +315,7 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re
Ok(_) => { Ok(_) => {
// Sync succeeded // Sync succeeded
if matches!(&choice, MainMenuChoice::Version(VersionKind::PyOxidizer(_))) { if matches!(&choice, MainMenuChoice::Version(VersionKind::PyOxidizer(_))) {
inject_helper_addon(&state.uv_install_root)?; inject_helper_addon()?;
} }
// Now that sync succeeded, save the previous version // Now that sync succeeded, save the previous version
@ -364,9 +386,7 @@ fn main_menu_loop(state: &State) -> Result<()> {
continue; continue;
} }
choice @ (MainMenuChoice::Latest | MainMenuChoice::Version(_)) => { choice @ (MainMenuChoice::Latest | MainMenuChoice::Version(_)) => {
if handle_version_install_or_update(state, choice.clone()).is_err() { handle_version_install_or_update(state, choice.clone())?;
continue;
}
break; break;
} }
} }
@ -374,12 +394,12 @@ fn main_menu_loop(state: &State) -> Result<()> {
Ok(()) Ok(())
} }
fn write_sync_marker(sync_complete_marker: &std::path::Path) -> Result<()> { fn write_sync_marker(state: &State) -> Result<()> {
let timestamp = SystemTime::now() let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.context("Failed to get system time")? .context("Failed to get system time")?
.as_secs(); .as_secs();
write_file(sync_complete_marker, timestamp.to_string())?; write_file(&state.sync_complete_marker, timestamp.to_string())?;
Ok(()) Ok(())
} }
@ -473,8 +493,6 @@ fn get_main_menu_choice(state: &State) -> Result<MainMenuChoice> {
} }
fn get_version_kind(state: &State) -> Result<Option<VersionKind>> { fn get_version_kind(state: &State) -> Result<Option<VersionKind>> {
println!("Please wait...");
let releases = get_releases(state)?; let releases = get_releases(state)?;
let releases_str = releases let releases_str = releases
.latest .latest
@ -633,15 +651,32 @@ fn fetch_versions(state: &State) -> Result<Vec<String>> {
let mut cmd = Command::new(&state.uv_path); let mut cmd = Command::new(&state.uv_path);
cmd.current_dir(&state.uv_install_root) cmd.current_dir(&state.uv_install_root)
.args(["run", "--no-project"]) .args(["run", "--no-project", "--no-config", "--managed-python"])
.arg(&versions_script); .args(["--with", "pip-system-certs"]);
let output = cmd.utf8_output()?; let python_version = read_file(&state.dist_python_version_path)?;
let python_version_str =
String::from_utf8(python_version).context("Invalid UTF-8 in .python-version")?;
let version_trimmed = python_version_str.trim();
if !version_trimmed.is_empty() {
cmd.args(["--python", version_trimmed]);
}
cmd.arg(&versions_script);
let output = match cmd.utf8_output() {
Ok(output) => output,
Err(e) => {
print!("Unable to check for Anki versions. Please check your internet connection.\n\n");
return Err(e.into());
}
};
let versions = serde_json::from_str(&output.stdout).context("Failed to parse versions JSON")?; let versions = serde_json::from_str(&output.stdout).context("Failed to parse versions JSON")?;
Ok(versions) Ok(versions)
} }
fn get_releases(state: &State) -> Result<Releases> { fn get_releases(state: &State) -> Result<Releases> {
println!("Checking for updates...");
let include_prereleases = state.prerelease_marker.exists(); let include_prereleases = state.prerelease_marker.exists();
let all_versions = fetch_versions(state)?; let all_versions = fetch_versions(state)?;
let all_versions = filter_and_normalize_versions(all_versions, include_prereleases); let all_versions = filter_and_normalize_versions(all_versions, include_prereleases);
@ -768,7 +803,7 @@ fn parse_version_kind(version: &str) -> Option<VersionKind> {
} }
} }
fn inject_helper_addon(_uv_install_root: &std::path::Path) -> Result<()> { fn inject_helper_addon() -> Result<()> {
let addons21_path = get_anki_addons21_path()?; let addons21_path = get_anki_addons21_path()?;
if !addons21_path.exists() { if !addons21_path.exists() {
@ -870,16 +905,24 @@ fn handle_uninstall(state: &State) -> Result<bool> {
Ok(true) Ok(true)
} }
fn have_developer_tools() -> bool {
Command::new("xcode-select")
.args(["-p"])
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
fn build_python_command(state: &State, args: &[String]) -> Result<Command> { fn build_python_command(state: &State, args: &[String]) -> Result<Command> {
let python_exe = if cfg!(target_os = "windows") { let python_exe = if cfg!(target_os = "windows") {
let show_console = std::env::var("ANKI_CONSOLE").is_ok(); let show_console = std::env::var("ANKI_CONSOLE").is_ok();
if show_console { if show_console {
state.uv_install_root.join(".venv/Scripts/python.exe") state.venv_folder.join("Scripts/python.exe")
} else { } else {
state.uv_install_root.join(".venv/Scripts/pythonw.exe") state.venv_folder.join("Scripts/pythonw.exe")
} }
} else { } else {
state.uv_install_root.join(".venv/bin/python") state.venv_folder.join("bin/python")
}; };
let mut cmd = Command::new(&python_exe); let mut cmd = Command::new(&python_exe);

View file

@ -5,6 +5,10 @@ import json
import sys import sys
import urllib.request import urllib.request
import pip_system_certs.wrapt_requests
pip_system_certs.wrapt_requests.inject_truststore()
def main(): def main():
"""Fetch and return all versions from PyPI, sorted by upload time.""" """Fetch and return all versions from PyPI, sorted by upload time."""

View file

@ -81,6 +81,7 @@ pin-project.workspace = true
prost.workspace = true prost.workspace = true
pulldown-cmark.workspace = true pulldown-cmark.workspace = true
rand.workspace = true rand.workspace = true
rayon.workspace = true
regex.workspace = true regex.workspace = true
reqwest.workspace = true reqwest.workspace = true
rusqlite.workspace = true rusqlite.workspace = true

View file

@ -11,6 +11,24 @@ use snafu::ensure;
use snafu::ResultExt; use snafu::ResultExt;
use snafu::Snafu; use snafu::Snafu;
#[derive(Debug)]
pub struct CodeDisplay(Option<i32>);
impl std::fmt::Display for CodeDisplay {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.0 {
Some(code) => write!(f, "{code}"),
None => write!(f, "?"),
}
}
}
impl From<Option<i32>> for CodeDisplay {
fn from(code: Option<i32>) -> Self {
CodeDisplay(code)
}
}
#[derive(Debug, Snafu)] #[derive(Debug, Snafu)]
pub enum Error { pub enum Error {
#[snafu(display("Failed to execute: {cmdline}"))] #[snafu(display("Failed to execute: {cmdline}"))]
@ -18,8 +36,15 @@ pub enum Error {
cmdline: String, cmdline: String,
source: std::io::Error, source: std::io::Error,
}, },
#[snafu(display("Failed with code {code:?}: {cmdline}"))] #[snafu(display("Failed to run ({code}): {cmdline}"))]
ReturnedError { cmdline: String, code: Option<i32> }, ReturnedError { cmdline: String, code: CodeDisplay },
#[snafu(display("Failed to run ({code}): {cmdline}: {stdout}{stderr}"))]
ReturnedWithOutputError {
cmdline: String,
code: CodeDisplay,
stdout: String,
stderr: String,
},
#[snafu(display("Couldn't decode stdout/stderr as utf8"))] #[snafu(display("Couldn't decode stdout/stderr as utf8"))]
InvalidUtf8 { InvalidUtf8 {
cmdline: String, cmdline: String,
@ -71,31 +96,36 @@ impl CommandExt for Command {
status.success(), status.success(),
ReturnedSnafu { ReturnedSnafu {
cmdline: get_cmdline(self), cmdline: get_cmdline(self),
code: status.code(), code: CodeDisplay::from(status.code()),
} }
); );
Ok(self) Ok(self)
} }
fn utf8_output(&mut self) -> Result<Utf8Output> { fn utf8_output(&mut self) -> Result<Utf8Output> {
let cmdline = get_cmdline(self);
let output = self.output().with_context(|_| DidNotExecuteSnafu { let output = self.output().with_context(|_| DidNotExecuteSnafu {
cmdline: get_cmdline(self), cmdline: cmdline.clone(),
})?; })?;
let stdout = String::from_utf8(output.stdout).with_context(|_| InvalidUtf8Snafu {
cmdline: cmdline.clone(),
})?;
let stderr = String::from_utf8(output.stderr).with_context(|_| InvalidUtf8Snafu {
cmdline: cmdline.clone(),
})?;
ensure!( ensure!(
output.status.success(), output.status.success(),
ReturnedSnafu { ReturnedWithOutputSnafu {
cmdline: get_cmdline(self), cmdline,
code: output.status.code(), code: CodeDisplay::from(output.status.code()),
stdout: stdout.clone(),
stderr: stderr.clone(),
} }
); );
Ok(Utf8Output {
stdout: String::from_utf8(output.stdout).with_context(|_| InvalidUtf8Snafu { Ok(Utf8Output { stdout, stderr })
cmdline: get_cmdline(self),
})?,
stderr: String::from_utf8(output.stderr).with_context(|_| InvalidUtf8Snafu {
cmdline: get_cmdline(self),
})?,
})
} }
fn ensure_spawn(&mut self) -> Result<std::process::Child> { fn ensure_spawn(&mut self) -> Result<std::process::Child> {
@ -135,7 +165,10 @@ mod test {
#[cfg(not(windows))] #[cfg(not(windows))]
assert!(matches!( assert!(matches!(
Command::new("false").ensure_success(), Command::new("false").ensure_success(),
Err(Error::ReturnedError { code: Some(1), .. }) Err(Error::ReturnedError {
code: CodeDisplay(_),
..
})
)); ));
} }
} }

View file

@ -212,10 +212,13 @@ impl Collection {
if fsrs_toggled { if fsrs_toggled {
self.set_config_bool_inner(BoolKey::Fsrs, req.fsrs)?; self.set_config_bool_inner(BoolKey::Fsrs, req.fsrs)?;
} }
let mut deck_desired_retention: HashMap<DeckId, f32> = Default::default();
for deck in self.storage.get_all_decks()? { for deck in self.storage.get_all_decks()? {
if let Ok(normal) = deck.normal() { if let Ok(normal) = deck.normal() {
let deck_id = deck.id; let deck_id = deck.id;
if let Some(desired_retention) = normal.desired_retention {
deck_desired_retention.insert(deck_id, desired_retention);
}
// previous order & params // previous order & params
let previous_config_id = DeckConfigId(normal.config_id); let previous_config_id = DeckConfigId(normal.config_id);
let previous_config = configs_before_update.get(&previous_config_id); let previous_config = configs_before_update.get(&previous_config_id);
@ -277,10 +280,11 @@ impl Collection {
if req.fsrs { if req.fsrs {
Some(UpdateMemoryStateRequest { Some(UpdateMemoryStateRequest {
params: c.fsrs_params().clone(), params: c.fsrs_params().clone(),
desired_retention: c.inner.desired_retention, preset_desired_retention: c.inner.desired_retention,
max_interval: c.inner.maximum_review_interval, max_interval: c.inner.maximum_review_interval,
reschedule: req.fsrs_reschedule, reschedule: req.fsrs_reschedule,
historical_retention: c.inner.historical_retention, historical_retention: c.inner.historical_retention,
deck_desired_retention: deck_desired_retention.clone(),
}) })
} else { } else {
None None
@ -409,6 +413,7 @@ fn normal_deck_to_limits(deck: &NormalDeck, today: u32) -> Limits {
.new_limit_today .new_limit_today
.map(|limit| limit.today == today) .map(|limit| limit.today == today)
.unwrap_or_default(), .unwrap_or_default(),
desired_retention: deck.desired_retention,
} }
} }
@ -417,6 +422,7 @@ fn update_deck_limits(deck: &mut NormalDeck, limits: &Limits, today: u32) {
deck.new_limit = limits.new; deck.new_limit = limits.new;
update_day_limit(&mut deck.review_limit_today, limits.review_today, today); update_day_limit(&mut deck.review_limit_today, limits.review_today, today);
update_day_limit(&mut deck.new_limit_today, limits.new_today, today); update_day_limit(&mut deck.new_limit_today, limits.new_today, today);
deck.desired_retention = limits.desired_retention;
} }
fn update_day_limit(day_limit: &mut Option<DayLimit>, new_limit: Option<u32>, today: u32) { fn update_day_limit(day_limit: &mut Option<DayLimit>, new_limit: Option<u32>, today: u32) {

View file

@ -31,6 +31,7 @@ pub(crate) use name::immediate_parent_name;
pub use name::NativeDeckName; pub use name::NativeDeckName;
pub use schema11::DeckSchema11; pub use schema11::DeckSchema11;
use crate::deckconfig::DeckConfig;
use crate::define_newtype; use crate::define_newtype;
use crate::error::FilteredDeckError; use crate::error::FilteredDeckError;
use crate::markdown::render_markdown; use crate::markdown::render_markdown;
@ -89,6 +90,16 @@ impl Deck {
} }
} }
/// Get the effective desired retention value for a deck.
/// Returns deck-specific desired retention if available, otherwise falls
/// back to config default.
pub fn effective_desired_retention(&self, config: &DeckConfig) -> f32 {
self.normal()
.ok()
.and_then(|d| d.desired_retention)
.unwrap_or(config.inner.desired_retention)
}
// used by tests at the moment // used by tests at the moment
#[allow(dead_code)] #[allow(dead_code)]

View file

@ -325,6 +325,7 @@ impl From<NormalDeckSchema11> for NormalDeck {
new_limit: deck.new_limit, new_limit: deck.new_limit,
review_limit_today: deck.review_limit_today, review_limit_today: deck.review_limit_today,
new_limit_today: deck.new_limit_today, new_limit_today: deck.new_limit_today,
desired_retention: None,
} }
} }
} }

View file

@ -444,6 +444,8 @@ impl Collection {
.get_deck(card.deck_id)? .get_deck(card.deck_id)?
.or_not_found(card.deck_id)?; .or_not_found(card.deck_id)?;
let config = self.home_deck_config(deck.config_id(), card.original_deck_id)?; let config = self.home_deck_config(deck.config_id(), card.original_deck_id)?;
let desired_retention = deck.effective_desired_retention(&config);
let fsrs_enabled = self.get_config_bool(BoolKey::Fsrs); let fsrs_enabled = self.get_config_bool(BoolKey::Fsrs);
let fsrs_next_states = if fsrs_enabled { let fsrs_next_states = if fsrs_enabled {
let params = config.fsrs_params(); let params = config.fsrs_params();
@ -473,13 +475,13 @@ impl Collection {
}; };
Some(fsrs.next_states( Some(fsrs.next_states(
card.memory_state.map(Into::into), card.memory_state.map(Into::into),
config.inner.desired_retention, desired_retention,
days_elapsed, days_elapsed,
)?) )?)
} else { } else {
None None
}; };
let desired_retention = fsrs_enabled.then_some(config.inner.desired_retention); let desired_retention = fsrs_enabled.then_some(desired_retention);
let fsrs_short_term_with_steps = let fsrs_short_term_with_steps =
self.get_config_bool(BoolKey::FsrsShortTermWithStepsEnabled); self.get_config_bool(BoolKey::FsrsShortTermWithStepsEnabled);
let fsrs_allow_short_term = if fsrs_enabled { let fsrs_allow_short_term = if fsrs_enabled {
@ -662,6 +664,43 @@ pub(crate) mod test {
col.get_scheduling_states(card_id).unwrap().current col.get_scheduling_states(card_id).unwrap().current
} }
// Test that deck-specific desired retention is used when available
#[test]
fn deck_specific_desired_retention() -> Result<()> {
let mut col = Collection::new();
// Enable FSRS
col.set_config_bool(BoolKey::Fsrs, true, false)?;
// Create a deck with specific desired retention
let deck_id = DeckId(1);
let deck = col.get_deck(deck_id)?.unwrap();
let mut deck_clone = (*deck).clone();
deck_clone.normal_mut().unwrap().desired_retention = Some(0.85);
col.update_deck(&mut deck_clone)?;
// Create a card in this deck
let nt = col.get_notetype_by_name("Basic")?.unwrap();
let mut note = nt.new_note();
col.add_note(&mut note, deck_id)?;
// Get the card using search_cards
let cards = col.search_cards(note.id, SortMode::NoOrder)?;
let card = col.storage.get_card(cards[0])?.unwrap();
// Test that the card state updater uses deck-specific desired retention
let updater = col.card_state_updater(card)?;
// Print debug information
println!("FSRS enabled: {}", col.get_config_bool(BoolKey::Fsrs));
println!("Desired retention: {:?}", updater.desired_retention);
// Verify that the desired retention is from the deck, not the config
assert_eq!(updater.desired_retention, Some(0.85));
Ok(())
}
// make sure the 'current' state for a card matches the // make sure the 'current' state for a card matches the
// state we applied to it // state we applied to it
#[test] #[test]

View file

@ -45,10 +45,11 @@ pub(crate) fn get_decay_from_params(params: &[f32]) -> f32 {
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct UpdateMemoryStateRequest { pub(crate) struct UpdateMemoryStateRequest {
pub params: Params, pub params: Params,
pub desired_retention: f32, pub preset_desired_retention: f32,
pub historical_retention: f32, pub historical_retention: f32,
pub max_interval: u32, pub max_interval: u32,
pub reschedule: bool, pub reschedule: bool,
pub deck_desired_retention: HashMap<DeckId, f32>,
} }
pub(crate) struct UpdateMemoryStateEntry { pub(crate) struct UpdateMemoryStateEntry {
@ -98,7 +99,8 @@ impl Collection {
historical_retention.unwrap_or(0.9), historical_retention.unwrap_or(0.9),
ignore_before, ignore_before,
)?; )?;
let desired_retention = req.as_ref().map(|w| w.desired_retention); let preset_desired_retention =
req.as_ref().map(|w| w.preset_desired_retention).unwrap();
let mut progress = self.new_progress_handler::<ComputeMemoryProgress>(); let mut progress = self.new_progress_handler::<ComputeMemoryProgress>();
progress.update(false, |s| s.total_cards = items.len() as u32)?; progress.update(false, |s| s.total_cards = items.len() as u32)?;
for (idx, (card_id, item)) in items.into_iter().enumerate() { for (idx, (card_id, item)) in items.into_iter().enumerate() {
@ -109,7 +111,12 @@ impl Collection {
// Store decay and desired retention in the card so that add-ons, card info, // Store decay and desired retention in the card so that add-ons, card info,
// stats and browser search/sorts don't need to access the deck config. // stats and browser search/sorts don't need to access the deck config.
// Unlike memory states, scheduler doesn't use decay and dr stored in the card. // Unlike memory states, scheduler doesn't use decay and dr stored in the card.
card.desired_retention = desired_retention; let deck_id = card.original_or_current_deck_id();
let desired_retention = *req
.deck_desired_retention
.get(&deck_id)
.unwrap_or(&preset_desired_retention);
card.desired_retention = Some(desired_retention);
card.decay = decay; card.decay = decay;
if let Some(item) = item { if let Some(item) = item {
card.set_memory_state(&fsrs, Some(item), historical_retention.unwrap())?; card.set_memory_state(&fsrs, Some(item), historical_retention.unwrap())?;
@ -132,7 +139,7 @@ impl Collection {
let original_interval = card.interval; let original_interval = card.interval;
let interval = fsrs.next_interval( let interval = fsrs.next_interval(
Some(state.stability), Some(state.stability),
desired_retention.unwrap(), desired_retention,
0, 0,
); );
card.interval = rescheduler card.interval = rescheduler
@ -205,7 +212,11 @@ impl Collection {
.storage .storage
.get_deck_config(conf_id)? .get_deck_config(conf_id)?
.or_not_found(conf_id)?; .or_not_found(conf_id)?;
let desired_retention = config.inner.desired_retention;
// Get deck-specific desired retention if available, otherwise use config
// default
let desired_retention = deck.effective_desired_retention(&config);
let historical_retention = config.inner.historical_retention; let historical_retention = config.inner.historical_retention;
let params = config.fsrs_params(); let params = config.fsrs_params();
let decay = get_decay_from_params(params); let decay = get_decay_from_params(params);

View file

@ -1,11 +1,13 @@
// 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
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use anki_proto::deck_config::deck_config::config::ReviewCardOrder; use anki_proto::deck_config::deck_config::config::ReviewCardOrder;
use anki_proto::deck_config::deck_config::config::ReviewCardOrder::*; use anki_proto::deck_config::deck_config::config::ReviewCardOrder::*;
use anki_proto::scheduler::SimulateFsrsReviewRequest; use anki_proto::scheduler::SimulateFsrsReviewRequest;
use anki_proto::scheduler::SimulateFsrsReviewResponse; use anki_proto::scheduler::SimulateFsrsReviewResponse;
use anki_proto::scheduler::SimulateFsrsWorkloadResponse;
use fsrs::simulate; use fsrs::simulate;
use fsrs::PostSchedulingFn; use fsrs::PostSchedulingFn;
use fsrs::ReviewPriorityFn; use fsrs::ReviewPriorityFn;
@ -14,6 +16,8 @@ use fsrs::FSRS;
use itertools::Itertools; use itertools::Itertools;
use rand::rngs::StdRng; use rand::rngs::StdRng;
use rand::Rng; use rand::Rng;
use rayon::iter::IntoParallelIterator;
use rayon::iter::ParallelIterator;
use crate::card::CardQueue; use crate::card::CardQueue;
use crate::card::CardType; use crate::card::CardType;
@ -267,6 +271,38 @@ impl Collection {
daily_time_cost: result.cost_per_day, daily_time_cost: result.cost_per_day,
}) })
} }
pub fn simulate_workload(
&mut self,
req: SimulateFsrsReviewRequest,
) -> Result<SimulateFsrsWorkloadResponse> {
let (config, cards) = self.simulate_request_to_config(&req)?;
let dr_workload = (70u32..=99u32)
.into_par_iter()
.map(|dr| {
let result = simulate(
&config,
&req.params,
dr as f32 / 100.,
None,
Some(cards.clone()),
)?;
Ok((
dr,
(
*result.memorized_cnt_per_day.last().unwrap_or(&0.),
result.cost_per_day.iter().sum::<f32>(),
result.review_cnt_per_day.iter().sum::<usize>() as u32,
),
))
})
.collect::<Result<HashMap<_, _>>>()?;
Ok(SimulateFsrsWorkloadResponse {
memorized: dr_workload.iter().map(|(k, v)| (*k, v.0)).collect(),
cost: dr_workload.iter().map(|(k, v)| (*k, v.1)).collect(),
review_count: dr_workload.iter().map(|(k, v)| (*k, v.2)).collect(),
})
}
} }
impl Card { impl Card {

View file

@ -16,6 +16,7 @@ use anki_proto::scheduler::FuzzDeltaResponse;
use anki_proto::scheduler::GetOptimalRetentionParametersResponse; use anki_proto::scheduler::GetOptimalRetentionParametersResponse;
use anki_proto::scheduler::SimulateFsrsReviewRequest; use anki_proto::scheduler::SimulateFsrsReviewRequest;
use anki_proto::scheduler::SimulateFsrsReviewResponse; use anki_proto::scheduler::SimulateFsrsReviewResponse;
use anki_proto::scheduler::SimulateFsrsWorkloadResponse;
use fsrs::ComputeParametersInput; use fsrs::ComputeParametersInput;
use fsrs::FSRSItem; use fsrs::FSRSItem;
use fsrs::FSRSReview; use fsrs::FSRSReview;
@ -283,6 +284,13 @@ impl crate::services::SchedulerService for Collection {
self.simulate_review(input) self.simulate_review(input)
} }
fn simulate_fsrs_workload(
&mut self,
input: SimulateFsrsReviewRequest,
) -> Result<SimulateFsrsWorkloadResponse> {
self.simulate_workload(input)
}
fn compute_optimal_retention( fn compute_optimal_retention(
&mut self, &mut self,
input: SimulateFsrsReviewRequest, input: SimulateFsrsReviewRequest,

View file

@ -12,14 +12,20 @@ impl Collection {
.map(component_to_regex) .map(component_to_regex)
.collect::<Result<_, _>>()?; .collect::<Result<_, _>>()?;
let mut tags = vec![]; let mut tags = vec![];
let mut priority = vec![];
self.storage.get_tags_by_predicate(|tag| { self.storage.get_tags_by_predicate(|tag| {
if tags.len() <= limit && filters_match(&filters, tag) { if priority.len() + tags.len() <= limit {
tags.push(tag.to_string()); match filters_match(&filters, tag) {
Some(true) => priority.push(tag.to_string()),
Some(_) => tags.push(tag.to_string()),
_ => {}
}
} }
// we only need the tag name // we only need the tag name
false false
})?; })?;
Ok(tags) priority.append(&mut tags);
Ok(priority)
} }
} }
@ -27,20 +33,26 @@ fn component_to_regex(component: &str) -> Result<Regex> {
Regex::new(&format!("(?i){}", regex::escape(component))).map_err(Into::into) Regex::new(&format!("(?i){}", regex::escape(component))).map_err(Into::into)
} }
fn filters_match(filters: &[Regex], tag: &str) -> bool { /// Returns None if tag wasn't a match, otherwise whether it was a consecutive
/// prefix match
fn filters_match(filters: &[Regex], tag: &str) -> Option<bool> {
let mut remaining_tag_components = tag.split("::"); let mut remaining_tag_components = tag.split("::");
let mut is_prefix = true;
'outer: for filter in filters { 'outer: for filter in filters {
loop { loop {
if let Some(component) = remaining_tag_components.next() { if let Some(component) = remaining_tag_components.next() {
if filter.is_match(component) { if let Some(m) = filter.find(component) {
is_prefix &= m.start() == 0;
continue 'outer; continue 'outer;
} else {
is_prefix = false;
} }
} else { } else {
return false; return None;
} }
} }
} }
true Some(is_prefix)
} }
#[cfg(test)] #[cfg(test)]
@ -50,28 +62,32 @@ mod test {
#[test] #[test]
fn matching() -> Result<()> { fn matching() -> Result<()> {
let filters = &[component_to_regex("b")?]; let filters = &[component_to_regex("b")?];
assert!(filters_match(filters, "ABC")); assert!(filters_match(filters, "ABC").is_some());
assert!(filters_match(filters, "ABC::def")); assert!(filters_match(filters, "ABC::def").is_some());
assert!(filters_match(filters, "def::abc")); assert!(filters_match(filters, "def::abc").is_some());
assert!(!filters_match(filters, "def")); assert!(filters_match(filters, "def").is_none());
let filters = &[component_to_regex("b")?, component_to_regex("E")?]; let filters = &[component_to_regex("b")?, component_to_regex("E")?];
assert!(!filters_match(filters, "ABC")); assert!(filters_match(filters, "ABC").is_none());
assert!(filters_match(filters, "ABC::def")); assert!(filters_match(filters, "ABC::def").is_some());
assert!(!filters_match(filters, "def::abc")); assert!(filters_match(filters, "def::abc").is_none());
assert!(!filters_match(filters, "def")); assert!(filters_match(filters, "def").is_none());
let filters = &[ let filters = &[
component_to_regex("a")?, component_to_regex("a")?,
component_to_regex("c")?, component_to_regex("c")?,
component_to_regex("e")?, component_to_regex("e")?,
]; ];
assert!(!filters_match(filters, "ace")); assert!(filters_match(filters, "ace").is_none());
assert!(!filters_match(filters, "a::c")); assert!(filters_match(filters, "a::c").is_none());
assert!(!filters_match(filters, "c::e")); assert!(filters_match(filters, "c::e").is_none());
assert!(filters_match(filters, "a::c::e")); assert!(filters_match(filters, "a::c::e").is_some());
assert!(filters_match(filters, "a::b::c::d::e")); assert!(filters_match(filters, "a::b::c::d::e").is_some());
assert!(filters_match(filters, "1::a::b::c::d::e::f")); assert!(filters_match(filters, "1::a::b::c::d::e::f").is_some());
assert_eq!(filters_match(filters, "a1::c2::e3"), Some(true));
assert_eq!(filters_match(filters, "a1::c2::?::e4"), Some(false));
assert_eq!(filters_match(filters, "a1::c2::3e"), Some(false));
Ok(()) Ok(())
} }

View file

@ -93,6 +93,10 @@ impl TimestampMillis {
pub fn adding_secs(self, secs: i64) -> Self { pub fn adding_secs(self, secs: i64) -> Self {
Self(self.0 + secs * 1000) Self(self.0 + secs * 1000)
} }
pub fn elapsed_millis(self) -> u64 {
(Self::now().0 - self.0).max(0) as u64
}
} }
fn elapsed() -> time::Duration { fn elapsed() -> time::Duration {

View file

@ -24,6 +24,7 @@ export const HelpPage = {
displayOrder: "https://docs.ankiweb.net/deck-options.html#display-order", displayOrder: "https://docs.ankiweb.net/deck-options.html#display-order",
maximumReviewsday: "https://docs.ankiweb.net/deck-options.html#maximum-reviewsday", maximumReviewsday: "https://docs.ankiweb.net/deck-options.html#maximum-reviewsday",
newCardsday: "https://docs.ankiweb.net/deck-options.html#new-cardsday", newCardsday: "https://docs.ankiweb.net/deck-options.html#new-cardsday",
limitsFromTop: "https://docs.ankiweb.net/deck-options.html#limits-start-from-top",
dailyLimits: "https://docs.ankiweb.net/deck-options.html#daily-limits", dailyLimits: "https://docs.ankiweb.net/deck-options.html#daily-limits",
audio: "https://docs.ankiweb.net/deck-options.html#audio", audio: "https://docs.ankiweb.net/deck-options.html#audio",
fsrs: "http://docs.ankiweb.net/deck-options.html#fsrs", fsrs: "http://docs.ankiweb.net/deck-options.html#fsrs",

View file

@ -140,7 +140,7 @@
applyAllParentLimits: { applyAllParentLimits: {
title: tr.deckConfigApplyAllParentLimits(), title: tr.deckConfigApplyAllParentLimits(),
help: applyAllParentLimitsHelp, help: applyAllParentLimitsHelp,
url: HelpPage.DeckOptions.newCardsday, url: HelpPage.DeckOptions.limitsFromTop,
global: true, global: true,
}, },
}; };

View file

@ -21,7 +21,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import SwitchRow from "$lib/components/SwitchRow.svelte"; import SwitchRow from "$lib/components/SwitchRow.svelte";
import GlobalLabel from "./GlobalLabel.svelte"; import GlobalLabel from "./GlobalLabel.svelte";
import { commitEditing, fsrsParams, type DeckOptionsState } from "./lib"; import { commitEditing, fsrsParams, type DeckOptionsState, ValueTab } from "./lib";
import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte"; import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte";
import Warning from "./Warning.svelte"; import Warning from "./Warning.svelte";
import ParamsInputRow from "./ParamsInputRow.svelte"; import ParamsInputRow from "./ParamsInputRow.svelte";
@ -33,6 +33,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
UpdateDeckConfigsMode, UpdateDeckConfigsMode,
} from "@generated/anki/deck_config_pb"; } from "@generated/anki/deck_config_pb";
import type Modal from "bootstrap/js/dist/modal"; import type Modal from "bootstrap/js/dist/modal";
import TabbedValue from "./TabbedValue.svelte";
import Item from "$lib/components/Item.svelte";
import DynamicallySlottable from "$lib/components/DynamicallySlottable.svelte";
export let state: DeckOptionsState; export let state: DeckOptionsState;
export let openHelpModal: (String) => void; export let openHelpModal: (String) => void;
@ -43,13 +46,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const defaults = state.defaults; const defaults = state.defaults;
const fsrsReschedule = state.fsrsReschedule; const fsrsReschedule = state.fsrsReschedule;
const daysSinceLastOptimization = state.daysSinceLastOptimization; const daysSinceLastOptimization = state.daysSinceLastOptimization;
const limits = state.deckLimits;
$: lastOptimizationWarning = $: lastOptimizationWarning =
$daysSinceLastOptimization > 30 ? tr.deckConfigTimeToOptimize() : ""; $daysSinceLastOptimization > 30 ? tr.deckConfigTimeToOptimize() : "";
let desiredRetentionFocused = false; let desiredRetentionFocused = false;
let desiredRetentionEverFocused = false; let desiredRetentionEverFocused = false;
let optimized = false; let optimized = false;
const startingDesiredRetention = $config.desiredRetention.toFixed(2);
$: if (desiredRetentionFocused) { $: if (desiredRetentionFocused) {
desiredRetentionEverFocused = true; desiredRetentionEverFocused = true;
} }
@ -64,7 +67,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: computing = computingParams || checkingParams; $: computing = computingParams || checkingParams;
$: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`; $: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`;
$: roundedRetention = Number($config.desiredRetention.toFixed(2)); $: roundedRetention = Number(effectiveDesiredRetention.toFixed(2));
$: desiredRetentionWarning = getRetentionLongShortWarning(roundedRetention); $: desiredRetentionWarning = getRetentionLongShortWarning(roundedRetention);
let desiredRetentionChangeInfo = ""; let desiredRetentionChangeInfo = "";
@ -76,6 +79,29 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit; $: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
// Create tabs for desired retention
const desiredRetentionTabs: ValueTab[] = [
new ValueTab(
tr.deckConfigSharedPreset(),
$config.desiredRetention,
(value) => ($config.desiredRetention = value!),
$config.desiredRetention,
null,
),
new ValueTab(
tr.deckConfigDeckOnly(),
$limits.desiredRetention ?? null,
(value) => ($limits.desiredRetention = value ?? undefined),
null,
null,
),
];
// Get the effective desired retention value (deck-specific if set, otherwise config default)
let effectiveDesiredRetention =
$limits.desiredRetention ?? $config.desiredRetention;
const startingDesiredRetention = effectiveDesiredRetention.toFixed(2);
$: simulateFsrsRequest = new SimulateFsrsReviewRequest({ $: simulateFsrsRequest = new SimulateFsrsReviewRequest({
params: fsrsParams($config), params: fsrsParams($config),
desiredRetention: $config.desiredRetention, desiredRetention: $config.desiredRetention,
@ -306,20 +332,40 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
let simulatorModal: Modal; let simulatorModal: Modal;
let workloadModal: Modal;
</script> </script>
<SpinBoxFloatRow <DynamicallySlottable slotHost={Item} api={{}}>
bind:value={$config.desiredRetention} <Item>
defaultValue={defaults.desiredRetention} <SpinBoxFloatRow
min={0.7} bind:value={effectiveDesiredRetention}
max={0.99} defaultValue={defaults.desiredRetention}
percentage={true} min={0.7}
bind:focused={desiredRetentionFocused} max={0.99}
percentage={true}
bind:focused={desiredRetentionFocused}
>
<TabbedValue
slot="tabs"
tabs={desiredRetentionTabs}
bind:value={effectiveDesiredRetention}
/>
<SettingTitle on:click={() => openHelpModal("desiredRetention")}>
{tr.deckConfigDesiredRetention()}
</SettingTitle>
</SpinBoxFloatRow>
</Item>
</DynamicallySlottable>
<button
class="btn btn-primary"
on:click={() => {
simulateFsrsRequest.reviewLimit = 9999;
workloadModal?.show();
}}
> >
<SettingTitle on:click={() => openHelpModal("desiredRetention")}> {tr.deckConfigFsrsDesiredRetentionHelpMeDecideExperimental()}
{tr.deckConfigDesiredRetention()} </button>
</SettingTitle>
</SpinBoxFloatRow>
<Warning warning={desiredRetentionChangeInfo} className={"alert-info two-line"} /> <Warning warning={desiredRetentionChangeInfo} className={"alert-info two-line"} />
<Warning warning={desiredRetentionWarning} className={retentionWarningClass} /> <Warning warning={desiredRetentionWarning} className={retentionWarningClass} />
@ -416,6 +462,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{onPresetChange} {onPresetChange}
/> />
<SimulatorModal
bind:modal={workloadModal}
workload
{state}
{simulateFsrsRequest}
{computing}
{openHelpModal}
{onPresetChange}
/>
<style> <style>
.btn { .btn {
margin-bottom: 0.375rem; margin-bottom: 0.375rem;

View file

@ -13,15 +13,25 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import TableData from "../graphs/TableData.svelte"; import TableData from "../graphs/TableData.svelte";
import InputBox from "../graphs/InputBox.svelte"; import InputBox from "../graphs/InputBox.svelte";
import { defaultGraphBounds, type TableDatum } from "../graphs/graph-helpers"; import { defaultGraphBounds, type TableDatum } from "../graphs/graph-helpers";
import { SimulateSubgraph, type Point } from "../graphs/simulator"; import {
SimulateSubgraph,
SimulateWorkloadSubgraph,
type Point,
type WorkloadPoint,
} from "../graphs/simulator";
import * as tr from "@generated/ftl"; import * as tr from "@generated/ftl";
import { renderSimulationChart } from "../graphs/simulator"; import { renderSimulationChart, renderWorkloadChart } from "../graphs/simulator";
import { computeOptimalRetention, simulateFsrsReview } from "@generated/backend"; import {
computeOptimalRetention,
simulateFsrsReview,
simulateFsrsWorkload,
} from "@generated/backend";
import { runWithBackendProgress } from "@tslib/progress"; import { runWithBackendProgress } from "@tslib/progress";
import type { import type {
ComputeOptimalRetentionResponse, ComputeOptimalRetentionResponse,
SimulateFsrsReviewRequest, SimulateFsrsReviewRequest,
SimulateFsrsReviewResponse, SimulateFsrsReviewResponse,
SimulateFsrsWorkloadResponse,
} from "@generated/anki/scheduler_pb"; } from "@generated/anki/scheduler_pb";
import type { DeckOptionsState } from "./lib"; import type { DeckOptionsState } from "./lib";
import SwitchRow from "$lib/components/SwitchRow.svelte"; import SwitchRow from "$lib/components/SwitchRow.svelte";
@ -34,15 +44,21 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import Warning from "./Warning.svelte"; import Warning from "./Warning.svelte";
import type { ComputeRetentionProgress } from "@generated/anki/collection_pb"; import type { ComputeRetentionProgress } from "@generated/anki/collection_pb";
import Modal from "bootstrap/js/dist/modal"; import Modal from "bootstrap/js/dist/modal";
import Row from "$lib/components/Row.svelte";
import Col from "$lib/components/Col.svelte";
export let state: DeckOptionsState; export let state: DeckOptionsState;
export let simulateFsrsRequest: SimulateFsrsReviewRequest; export let simulateFsrsRequest: SimulateFsrsReviewRequest;
export let computing: boolean; export let computing: boolean;
export let openHelpModal: (key: string) => void; export let openHelpModal: (key: string) => void;
export let onPresetChange: () => void; export let onPresetChange: () => void;
/** Do not modify this once set */
export let workload: boolean = false;
const config = state.currentConfig; const config = state.currentConfig;
let simulateSubgraph: SimulateSubgraph = SimulateSubgraph.count; let simulateSubgraph: SimulateSubgraph = SimulateSubgraph.count;
let simulateWorkloadSubgraph: SimulateWorkloadSubgraph =
SimulateWorkloadSubgraph.ratio;
let tableData: TableDatum[] = []; let tableData: TableDatum[] = [];
let simulating: boolean = false; let simulating: boolean = false;
const fsrs = state.fsrs; const fsrs = state.fsrs;
@ -50,7 +66,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let svg: HTMLElement | SVGElement | null = null; let svg: HTMLElement | SVGElement | null = null;
let simulationNumber = 0; let simulationNumber = 0;
let points: Point[] = []; let points: (WorkloadPoint | Point)[] = [];
const newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit; const newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
let smooth = true; let smooth = true;
let suspendLeeches = $config.leechAction == DeckConfig_Config_LeechAction.SUSPEND; let suspendLeeches = $config.leechAction == DeckConfig_Config_LeechAction.SUSPEND;
@ -177,6 +193,43 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
} }
async function simulateWorkload(): Promise<void> {
let resp: SimulateFsrsWorkloadResponse | undefined;
updateRequest();
try {
await runWithBackendProgress(
async () => {
simulating = true;
resp = await simulateFsrsWorkload(simulateFsrsRequest);
},
() => {},
);
} finally {
simulating = false;
if (resp) {
simulationNumber += 1;
points = points.concat(
Object.entries(resp.memorized).map(([dr, v]) => ({
x: parseInt(dr),
timeCost: resp!.cost[dr],
memorized: v,
count: resp!.reviewCount[dr],
label: simulationNumber,
learnSpan: simulateFsrsRequest.daysToSimulate,
})),
);
tableData = renderWorkloadChart(
svg as SVGElement,
bounds,
points as WorkloadPoint[],
simulateWorkloadSubgraph,
);
}
}
}
function clearSimulation() { function clearSimulation() {
points = points.filter((p) => p.label !== simulationNumber); points = points.filter((p) => p.label !== simulationNumber);
simulationNumber = Math.max(0, simulationNumber - 1); simulationNumber = Math.max(0, simulationNumber - 1);
@ -188,6 +241,25 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
); );
} }
function saveConfigToPreset() {
if (confirm(tr.deckConfigSaveOptionsToPresetConfirm())) {
$config.newPerDay = simulateFsrsRequest.newLimit;
$config.reviewsPerDay = simulateFsrsRequest.reviewLimit;
$config.maximumReviewInterval = simulateFsrsRequest.maxInterval;
if (!workload) {
$config.desiredRetention = simulateFsrsRequest.desiredRetention;
}
$newCardsIgnoreReviewLimit = simulateFsrsRequest.newCardsIgnoreReviewLimit;
$config.reviewOrder = simulateFsrsRequest.reviewOrder;
$config.leechAction = suspendLeeches
? DeckConfig_Config_LeechAction.SUSPEND
: DeckConfig_Config_LeechAction.TAG_ONLY;
$config.leechThreshold = leechThreshold;
$config.easyDaysPercentages = [...easyDayPercentages];
onPresetChange();
}
}
$: if (svg) { $: if (svg) {
let pointsToRender = points; let pointsToRender = points;
if (smooth) { if (smooth) {
@ -225,11 +297,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}); });
} }
tableData = renderSimulationChart( const render_function = workload ? renderWorkloadChart : renderSimulationChart;
tableData = render_function(
svg as SVGElement, svg as SVGElement,
bounds, bounds,
pointsToRender, // This cast shouldn't matter because we aren't switching between modes in the same modal
simulateSubgraph, pointsToRender as WorkloadPoint[],
(workload ? simulateWorkloadSubgraph : simulateSubgraph) as any as never,
); );
} }
@ -252,7 +327,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<div class="modal-dialog modal-xl"> <div class="modal-dialog modal-xl">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">{tr.deckConfigFsrsSimulatorExperimental()}</h5> <h5 class="modal-title">
{#if workload}
{tr.deckConfigFsrsSimulateDesiredRetentionExperimental()}
{:else}
{tr.deckConfigFsrsSimulatorExperimental()}
{/if}
</h5>
<button <button
type="button" type="button"
class="btn-close" class="btn-close"
@ -278,17 +359,38 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</SettingTitle> </SettingTitle>
</SpinBoxRow> </SpinBoxRow>
<SpinBoxFloatRow {#if !workload}
bind:value={simulateFsrsRequest.desiredRetention} <SpinBoxFloatRow
defaultValue={$config.desiredRetention} bind:value={simulateFsrsRequest.desiredRetention}
min={0.7} defaultValue={$config.desiredRetention}
max={0.99} min={0.7}
percentage={true} max={0.99}
> percentage={true}
<SettingTitle on:click={() => openHelpModal("desiredRetention")}> >
{tr.deckConfigDesiredRetention()} <SettingTitle
</SettingTitle> on:click={() => openHelpModal("desiredRetention")}
</SpinBoxFloatRow> >
{tr.deckConfigDesiredRetention()}
</SettingTitle>
</SpinBoxFloatRow>
{:else}
<Row --cols={13}>
<Col --col-size={7} breakpoint="xs">
<SettingTitle
on:click={() => openHelpModal("desiredRetention")}
>
{tr.deckConfigDesiredRetention()}
</SettingTitle>
</Col>
<Col --col-size={6} breakpoint="xs">
<input
type="text"
disabled
value={tr.deckConfigPlottedOnXAxis()}
/>
</Col>
</Row>
{/if}
<SpinBoxRow <SpinBoxRow
bind:value={simulateFsrsRequest.newLimit} bind:value={simulateFsrsRequest.newLimit}
@ -421,79 +523,99 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{/if} {/if}
</details> </details>
</div> </div>
<button
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
disabled={computing}
on:click={simulateFsrs}
>
{tr.deckConfigSimulate()}
</button>
<button <div>
class="btn {computing ? 'btn-warning' : 'btn-primary'}" <button
disabled={computing} class="btn {computing ? 'btn-warning' : 'btn-primary'}"
on:click={clearSimulation} disabled={computing}
> on:click={workload ? simulateWorkload : simulateFsrs}
{tr.deckConfigClearLastSimulate()} >
</button> {tr.deckConfigSimulate()}
</button>
<button <button
class="btn {computing ? 'btn-warning' : 'btn-primary'}" class="btn {computing ? 'btn-warning' : 'btn-primary'}"
disabled={computing} disabled={computing}
on:click={() => { on:click={clearSimulation}
if (confirm(tr.deckConfigSaveOptionsToPresetConfirm())) { >
$config.newPerDay = simulateFsrsRequest.newLimit; {tr.deckConfigClearLastSimulate()}
$config.reviewsPerDay = simulateFsrsRequest.reviewLimit; </button>
$config.maximumReviewInterval =
simulateFsrsRequest.maxInterval;
$config.desiredRetention =
simulateFsrsRequest.desiredRetention;
$newCardsIgnoreReviewLimit =
simulateFsrsRequest.newCardsIgnoreReviewLimit;
$config.reviewOrder = simulateFsrsRequest.reviewOrder;
$config.leechAction = suspendLeeches
? DeckConfig_Config_LeechAction.SUSPEND
: DeckConfig_Config_LeechAction.TAG_ONLY;
$config.leechThreshold = leechThreshold;
$config.easyDaysPercentages = [...easyDayPercentages];
onPresetChange();
}
}}
>
{tr.deckConfigSaveOptionsToPreset()}
</button>
{#if processing} <button
{tr.actionsProcessing()} class="btn {computing ? 'btn-warning' : 'btn-primary'}"
{/if} disabled={computing}
on:click={saveConfigToPreset}
>
{tr.deckConfigSaveOptionsToPreset()}
</button>
{#if processing}
{tr.actionsProcessing()}
{/if}
</div>
<Graph> <Graph>
<div class="radio-group"> <div class="radio-group">
<InputBox> <InputBox>
<label> {#if !workload}
<input <label>
type="radio" <input
value={SimulateSubgraph.count} type="radio"
bind:group={simulateSubgraph} value={SimulateSubgraph.count}
/> bind:group={simulateSubgraph}
{tr.deckConfigFsrsSimulatorRadioCount()} />
</label> {tr.deckConfigFsrsSimulatorRadioCount()}
<label> </label>
<input <label>
type="radio" <input
value={SimulateSubgraph.time} type="radio"
bind:group={simulateSubgraph} value={SimulateSubgraph.time}
/> bind:group={simulateSubgraph}
{tr.statisticsReviewsTimeCheckbox()} />
</label> {tr.statisticsReviewsTimeCheckbox()}
<label> </label>
<input <label>
type="radio" <input
value={SimulateSubgraph.memorized} type="radio"
bind:group={simulateSubgraph} value={SimulateSubgraph.memorized}
/> bind:group={simulateSubgraph}
{tr.deckConfigFsrsSimulatorRadioMemorized()} />
</label> {tr.deckConfigFsrsSimulatorRadioMemorized()}
</label>
{:else}
<label>
<input
type="radio"
value={SimulateWorkloadSubgraph.ratio}
bind:group={simulateWorkloadSubgraph}
/>
{tr.deckConfigFsrsSimulatorRadioRatio()}
</label>
<label>
<input
type="radio"
value={SimulateWorkloadSubgraph.count}
bind:group={simulateWorkloadSubgraph}
/>
{tr.deckConfigFsrsSimulatorRadioCount()}
</label>
<label>
<input
type="radio"
value={SimulateWorkloadSubgraph.time}
bind:group={simulateWorkloadSubgraph}
/>
{tr.statisticsReviewsTimeCheckbox()}
</label>
<label>
<input
type="radio"
value={SimulateWorkloadSubgraph.memorized}
bind:group={simulateWorkloadSubgraph}
/>
{tr.deckConfigFsrsSimulatorRadioMemorized()}
</label>
{/if}
</InputBox> </InputBox>
</div> </div>

View file

@ -23,9 +23,12 @@
<slot /> <slot />
</Col> </Col>
<Col --col-size={6} breakpoint="xs"> <Col --col-size={6} breakpoint="xs">
<ConfigInput> <Row class="flex-grow-1">
<SpinBox bind:value {min} {max} {step} {percentage} bind:focused /> <slot name="tabs" />
<RevertButton slot="revert" bind:value {defaultValue} /> <ConfigInput>
</ConfigInput> <SpinBox bind:value {min} {max} {step} {percentage} bind:focused />
<RevertButton slot="revert" bind:value {defaultValue} />
</ConfigInput>
</Row>
</Col> </Col>
</Row> </Row>

View file

@ -31,50 +31,86 @@ export interface Point {
label: number; label: number;
} }
export type WorkloadPoint = Point & {
learnSpan: number;
};
export enum SimulateSubgraph { export enum SimulateSubgraph {
time, time,
count, count,
memorized, memorized,
} }
export enum SimulateWorkloadSubgraph {
ratio,
time,
count,
memorized,
}
export function renderWorkloadChart(
svgElem: SVGElement,
bounds: GraphBounds,
data: WorkloadPoint[],
subgraph: SimulateWorkloadSubgraph,
) {
const xMin = 70;
const xMax = 99;
const x = scaleLinear()
.domain([xMin, xMax])
.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
const subgraph_data = ({
[SimulateWorkloadSubgraph.ratio]: data.map(d => ({ ...d, y: d.timeCost / d.memorized })),
[SimulateWorkloadSubgraph.time]: data.map(d => ({ ...d, y: d.timeCost / d.learnSpan })),
[SimulateWorkloadSubgraph.count]: data.map(d => ({ ...d, y: d.count / d.learnSpan })),
[SimulateWorkloadSubgraph.memorized]: data.map(d => ({ ...d, y: d.memorized })),
})[subgraph];
const yTickFormat = (n: number): string => {
return subgraph == SimulateWorkloadSubgraph.time || subgraph == SimulateWorkloadSubgraph.ratio
? timeSpan(n, true)
: n.toString();
};
const formatY: (value: number) => string = ({
[SimulateWorkloadSubgraph.ratio]: (value: number) =>
tr.deckConfigFsrsSimulatorRatioTooltip({ time: timeSpan(value) }),
[SimulateWorkloadSubgraph.time]: (value: number) =>
tr.statisticsMinutesPerDay({ count: parseFloat((value / 60).toPrecision(2)) }),
[SimulateWorkloadSubgraph.count]: (value: number) => tr.statisticsReviewsPerDay({ count: Math.round(value) }),
[SimulateWorkloadSubgraph.memorized]: (value: number) =>
tr.statisticsMemorized({ memorized: Math.round(value).toFixed(0) }),
})[subgraph];
function formatX(dr: number) {
return `Desired Retention: ${dr}%<br>`;
}
return _renderSimulationChart(
svgElem,
bounds,
subgraph_data,
x,
yTickFormat,
formatY,
formatX,
(_e: MouseEvent, _d: number) => undefined,
);
}
export function renderSimulationChart( export function renderSimulationChart(
svgElem: SVGElement, svgElem: SVGElement,
bounds: GraphBounds, bounds: GraphBounds,
data: Point[], data: Point[],
subgraph: SimulateSubgraph, subgraph: SimulateSubgraph,
): TableDatum[] { ): TableDatum[] {
const svg = select(svgElem);
svg.selectAll(".lines").remove();
svg.selectAll(".hover-columns").remove();
svg.selectAll(".focus-line").remove();
svg.selectAll(".legend").remove();
if (data.length == 0) {
setDataAvailable(svg, false);
return [];
}
const trans = svg.transition().duration(600) as any;
// Prepare data
const today = new Date(); const today = new Date();
const convertedData = data.map(d => ({ const convertedData = data.map(d => ({
...d, ...d,
date: new Date(today.getTime() + d.x * 24 * 60 * 60 * 1000), x: new Date(today.getTime() + d.x * 24 * 60 * 60 * 1000),
})); }));
const xMin = today;
const xMax = max(convertedData, d => d.date);
const x = scaleTime()
.domain([xMin, xMax!])
.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
svg.select<SVGGElement>(".x-ticks")
.call((selection) => selection.transition(trans).call(axisBottom(x).ticks(7).tickSizeOuter(0)))
.attr("direction", "ltr");
// y scale
const yTickFormat = (n: number): string => {
return subgraph == SimulateSubgraph.time ? timeSpan(n, true) : n.toString();
};
const subgraph_data = ({ const subgraph_data = ({
[SimulateSubgraph.count]: convertedData.map(d => ({ ...d, y: d.count })), [SimulateSubgraph.count]: convertedData.map(d => ({ ...d, y: d.count })),
@ -82,6 +118,90 @@ export function renderSimulationChart(
[SimulateSubgraph.memorized]: convertedData.map(d => ({ ...d, y: d.memorized })), [SimulateSubgraph.memorized]: convertedData.map(d => ({ ...d, y: d.memorized })),
})[subgraph]; })[subgraph];
const xMin = today;
const xMax = max(subgraph_data, d => d.x);
const x = scaleTime()
.domain([xMin, xMax!])
.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
const yTickFormat = (n: number): string => {
return subgraph == SimulateSubgraph.time ? timeSpan(n, true) : n.toString();
};
const formatY: (value: number) => string = ({
[SimulateSubgraph.time]: timeSpan,
[SimulateSubgraph.count]: (value: number) => tr.statisticsReviews({ reviews: Math.round(value) }),
[SimulateSubgraph.memorized]: (value: number) =>
tr.statisticsMemorized({ memorized: Math.round(value).toFixed(0) }),
})[subgraph];
const perDay = ({
[SimulateSubgraph.count]: tr.statisticsReviewsPerDay,
[SimulateSubgraph.time]: ({ count }: { count: number }) => timeSpan(count),
[SimulateSubgraph.memorized]: tr.statisticsCardsPerDay,
})[subgraph];
function legendMouseMove(e: MouseEvent, d: number) {
const data = subgraph_data.filter(datum => datum.label == d);
const total = subgraph == SimulateSubgraph.memorized
? data[data.length - 1].memorized - data[0].memorized
: sumBy(data, d => d.y);
const average = total / (data?.length || 1);
showTooltip(
`#${d}:<br/>
${tr.statisticsAverage()}: ${perDay({ count: average })}<br/>
${tr.statisticsTotal()}: ${formatY(total)}`,
e.pageX,
e.pageY,
);
}
function formatX(date: Date) {
const days = +((date.getTime() - Date.now()) / (60 * 60 * 24 * 1000)).toFixed();
return `Date: ${localizedDate(date)}<br>In ${days} Days<br>`;
}
return _renderSimulationChart(
svgElem,
bounds,
subgraph_data,
x,
yTickFormat,
formatY,
formatX,
legendMouseMove,
);
}
function _renderSimulationChart<T extends { x: any; y: any; label: number }>(
svgElem: SVGElement,
bounds: GraphBounds,
subgraph_data: T[],
x: any,
yTickFormat: (n: number) => string,
formatY: (n: T["y"]) => string,
formatX: (n: T["x"]) => string,
legendMouseMove: (e: MouseEvent, d: number) => void,
): TableDatum[] {
const svg = select(svgElem);
svg.selectAll(".lines").remove();
svg.selectAll(".hover-columns").remove();
svg.selectAll(".focus-line").remove();
svg.selectAll(".legend").remove();
if (subgraph_data.length == 0) {
setDataAvailable(svg, false);
return [];
}
const trans = svg.transition().duration(600) as any;
svg.select<SVGGElement>(".x-ticks")
.call((selection) => selection.transition(trans).call(axisBottom(x).ticks(7).tickSizeOuter(0)))
.attr("direction", "ltr");
// y scale
const yMax = max(subgraph_data, d => d.y)!; const yMax = max(subgraph_data, d => d.y)!;
const y = scaleLinear() const y = scaleLinear()
.range([bounds.height - bounds.marginBottom, bounds.marginTop]) .range([bounds.height - bounds.marginBottom, bounds.marginTop])
@ -110,7 +230,7 @@ export function renderSimulationChart(
.attr("fill", "currentColor"); .attr("fill", "currentColor");
// x lines // x lines
const points = subgraph_data.map((d) => [x(d.date), y(d.y), d.label]); const points = subgraph_data.map((d) => [x(d.x), y(d.y), d.label]);
const groups = rollup(points, v => Object.assign(v, { z: v[0][2] }), d => d[2]); const groups = rollup(points, v => Object.assign(v, { z: v[0][2] }), d => d[2]);
const color = schemeCategory10; const color = schemeCategory10;
@ -157,13 +277,6 @@ export function renderSimulationChart(
hideTooltip(); hideTooltip();
}); });
const formatY: (value: number) => string = ({
[SimulateSubgraph.time]: timeSpan,
[SimulateSubgraph.count]: (value: number) => tr.statisticsReviews({ reviews: Math.round(value) }),
[SimulateSubgraph.memorized]: (value: number) =>
tr.statisticsMemorized({ memorized: Math.round(value).toFixed(0) }),
})[subgraph];
function mousemove(event: MouseEvent, d: any): void { function mousemove(event: MouseEvent, d: any): void {
pointer(event, document.body); pointer(event, document.body);
const date = x.invert(d[0]); const date = x.invert(d[0]);
@ -182,8 +295,7 @@ export function renderSimulationChart(
focusLine.attr("x1", d[0]).attr("x2", d[0]).style("opacity", 1); focusLine.attr("x1", d[0]).attr("x2", d[0]).style("opacity", 1);
const days = +((date.getTime() - Date.now()) / (60 * 60 * 24 * 1000)).toFixed(); let tooltipContent = formatX(date);
let tooltipContent = `Date: ${localizedDate(date)}<br>In ${days} Days<br>`;
for (const [key, value] of Object.entries(groupData)) { for (const [key, value] of Object.entries(groupData)) {
const path = svg.select(`path[data-group="${key}"]`); const path = svg.select(`path[data-group="${key}"]`);
const hidden = path.classed("hidden"); const hidden = path.classed("hidden");
@ -212,29 +324,6 @@ export function renderSimulationChart(
.on("mousemove", legendMouseMove) .on("mousemove", legendMouseMove)
.on("mouseout", hideTooltip); .on("mouseout", hideTooltip);
const perDay = ({
[SimulateSubgraph.count]: tr.statisticsReviewsPerDay,
[SimulateSubgraph.time]: ({ count }: { count: number }) => timeSpan(count),
[SimulateSubgraph.memorized]: tr.statisticsCardsPerDay,
})[subgraph];
function legendMouseMove(e: MouseEvent, d: number) {
const data = subgraph_data.filter(datum => datum.label == d);
const total = subgraph == SimulateSubgraph.memorized
? data[data.length - 1].memorized - data[0].memorized
: sumBy(data, d => d.y);
const average = total / (data?.length || 1);
showTooltip(
`#${d}:<br/>
${tr.statisticsAverage()}: ${perDay({ count: average })}<br/>
${tr.statisticsTotal()}: ${formatY(total)}`,
e.pageX,
e.pageY,
);
}
legend.append("rect") legend.append("rect")
.attr("x", bounds.width - bounds.marginRight + 36) .attr("x", bounds.width - bounds.marginRight + 36)
.attr("width", 12) .attr("width", 12)

View file

@ -106,6 +106,9 @@ function initCanvas(): fabric.Canvas {
fabric.Object.prototype.cornerStyle = "circle"; fabric.Object.prototype.cornerStyle = "circle";
fabric.Object.prototype.cornerStrokeColor = "#000000"; fabric.Object.prototype.cornerStrokeColor = "#000000";
fabric.Object.prototype.padding = 8; fabric.Object.prototype.padding = 8;
// snap rotation around 0 by +-3deg
fabric.Object.prototype.snapAngle = 360;
fabric.Object.prototype.snapThreshold = 3;
// disable rotation when selecting // disable rotation when selecting
canvas.on("selection:created", () => { canvas.on("selection:created", () => {
const g = canvas.getActiveObject(); const g = canvas.getActiveObject();

106
yarn.lock
View file

@ -2255,6 +2255,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2":
version: 1.0.2
resolution: "call-bind-apply-helpers@npm:1.0.2"
dependencies:
es-errors: "npm:^1.3.0"
function-bind: "npm:^1.1.2"
checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938
languageName: node
linkType: hard
"call-bind@npm:^1.0.2, call-bind@npm:^1.0.5, call-bind@npm:^1.0.6, call-bind@npm:^1.0.7": "call-bind@npm:^1.0.2, call-bind@npm:^1.0.5, call-bind@npm:^1.0.6, call-bind@npm:^1.0.7":
version: 1.0.7 version: 1.0.7
resolution: "call-bind@npm:1.0.7" resolution: "call-bind@npm:1.0.7"
@ -3120,6 +3130,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"dunder-proto@npm:^1.0.1":
version: 1.0.1
resolution: "dunder-proto@npm:1.0.1"
dependencies:
call-bind-apply-helpers: "npm:^1.0.1"
es-errors: "npm:^1.3.0"
gopd: "npm:^1.2.0"
checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031
languageName: node
linkType: hard
"eastasianwidth@npm:^0.2.0": "eastasianwidth@npm:^0.2.0":
version: 0.2.0 version: 0.2.0
resolution: "eastasianwidth@npm:0.2.0" resolution: "eastasianwidth@npm:0.2.0"
@ -3241,6 +3262,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"es-define-property@npm:^1.0.1":
version: 1.0.1
resolution: "es-define-property@npm:1.0.1"
checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c
languageName: node
linkType: hard
"es-errors@npm:^1.2.1, es-errors@npm:^1.3.0": "es-errors@npm:^1.2.1, es-errors@npm:^1.3.0":
version: 1.3.0 version: 1.3.0
resolution: "es-errors@npm:1.3.0" resolution: "es-errors@npm:1.3.0"
@ -3264,6 +3292,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"es-object-atoms@npm:^1.1.1":
version: 1.1.1
resolution: "es-object-atoms@npm:1.1.1"
dependencies:
es-errors: "npm:^1.3.0"
checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c
languageName: node
linkType: hard
"es-set-tostringtag@npm:^2.0.3": "es-set-tostringtag@npm:^2.0.3":
version: 2.0.3 version: 2.0.3
resolution: "es-set-tostringtag@npm:2.0.3" resolution: "es-set-tostringtag@npm:2.0.3"
@ -3275,6 +3312,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"es-set-tostringtag@npm:^2.1.0":
version: 2.1.0
resolution: "es-set-tostringtag@npm:2.1.0"
dependencies:
es-errors: "npm:^1.3.0"
get-intrinsic: "npm:^1.2.6"
has-tostringtag: "npm:^1.0.2"
hasown: "npm:^2.0.2"
checksum: 10c0/ef2ca9ce49afe3931cb32e35da4dcb6d86ab02592cfc2ce3e49ced199d9d0bb5085fc7e73e06312213765f5efa47cc1df553a6a5154584b21448e9fb8355b1af
languageName: node
linkType: hard
"es-shim-unscopables@npm:^1.0.0, es-shim-unscopables@npm:^1.0.2": "es-shim-unscopables@npm:^1.0.0, es-shim-unscopables@npm:^1.0.2":
version: 1.0.2 version: 1.0.2
resolution: "es-shim-unscopables@npm:1.0.2" resolution: "es-shim-unscopables@npm:1.0.2"
@ -3947,13 +3996,15 @@ __metadata:
linkType: hard linkType: hard
"form-data@npm:^4.0.0": "form-data@npm:^4.0.0":
version: 4.0.1 version: 4.0.4
resolution: "form-data@npm:4.0.1" resolution: "form-data@npm:4.0.4"
dependencies: dependencies:
asynckit: "npm:^0.4.0" asynckit: "npm:^0.4.0"
combined-stream: "npm:^1.0.8" combined-stream: "npm:^1.0.8"
es-set-tostringtag: "npm:^2.1.0"
hasown: "npm:^2.0.2"
mime-types: "npm:^2.1.12" mime-types: "npm:^2.1.12"
checksum: 10c0/bb102d570be8592c23f4ea72d7df9daa50c7792eb0cf1c5d7e506c1706e7426a4e4ae48a35b109e91c85f1c0ec63774a21ae252b66f4eb981cb8efef7d0463c8 checksum: 10c0/373525a9a034b9d57073e55eab79e501a714ffac02e7a9b01be1c820780652b16e4101819785e1e18f8d98f0aee866cc654d660a435c378e16a72f2e7cac9695
languageName: node languageName: node
linkType: hard linkType: hard
@ -4031,6 +4082,34 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"get-intrinsic@npm:^1.2.6":
version: 1.3.0
resolution: "get-intrinsic@npm:1.3.0"
dependencies:
call-bind-apply-helpers: "npm:^1.0.2"
es-define-property: "npm:^1.0.1"
es-errors: "npm:^1.3.0"
es-object-atoms: "npm:^1.1.1"
function-bind: "npm:^1.1.2"
get-proto: "npm:^1.0.1"
gopd: "npm:^1.2.0"
has-symbols: "npm:^1.1.0"
hasown: "npm:^2.0.2"
math-intrinsics: "npm:^1.1.0"
checksum: 10c0/52c81808af9a8130f581e6a6a83e1ba4a9f703359e7a438d1369a5267a25412322f03dcbd7c549edaef0b6214a0630a28511d7df0130c93cfd380f4fa0b5b66a
languageName: node
linkType: hard
"get-proto@npm:^1.0.1":
version: 1.0.1
resolution: "get-proto@npm:1.0.1"
dependencies:
dunder-proto: "npm:^1.0.1"
es-object-atoms: "npm:^1.0.0"
checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c
languageName: node
linkType: hard
"get-symbol-description@npm:^1.0.2": "get-symbol-description@npm:^1.0.2":
version: 1.0.2 version: 1.0.2
resolution: "get-symbol-description@npm:1.0.2" resolution: "get-symbol-description@npm:1.0.2"
@ -4141,6 +4220,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"gopd@npm:^1.2.0":
version: 1.2.0
resolution: "gopd@npm:1.2.0"
checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead
languageName: node
linkType: hard
"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.2.6": "graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.2.6":
version: 4.2.11 version: 4.2.11
resolution: "graceful-fs@npm:4.2.11" resolution: "graceful-fs@npm:4.2.11"
@ -4199,6 +4285,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"has-symbols@npm:^1.1.0":
version: 1.1.0
resolution: "has-symbols@npm:1.1.0"
checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e
languageName: node
linkType: hard
"has-tostringtag@npm:^1.0.0, has-tostringtag@npm:^1.0.2": "has-tostringtag@npm:^1.0.0, has-tostringtag@npm:^1.0.2":
version: 1.0.2 version: 1.0.2
resolution: "has-tostringtag@npm:1.0.2" resolution: "has-tostringtag@npm:1.0.2"
@ -4920,6 +5013,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"math-intrinsics@npm:^1.1.0":
version: 1.1.0
resolution: "math-intrinsics@npm:1.1.0"
checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f
languageName: node
linkType: hard
"mathjax@npm:^3.1.2": "mathjax@npm:^3.1.2":
version: 3.2.2 version: 3.2.2
resolution: "mathjax@npm:3.2.2" resolution: "mathjax@npm:3.2.2"