mirror of
https://github.com/ankitects/anki.git
synced 2025-09-24 16:56:36 -04:00
Merge branch 'main' into cached-workload
This commit is contained in:
commit
54bf0bd162
37 changed files with 878 additions and 268 deletions
2
.version
2
.version
|
@ -1 +1 @@
|
|||
25.07.3
|
||||
25.07.5
|
||||
|
|
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -131,6 +131,7 @@ dependencies = [
|
|||
"prost-reflect",
|
||||
"pulldown-cmark 0.13.0",
|
||||
"rand 0.9.1",
|
||||
"rayon",
|
||||
"regex",
|
||||
"reqwest 0.12.20",
|
||||
"rusqlite",
|
||||
|
|
|
@ -111,6 +111,7 @@ prost-types = "0.13"
|
|||
pulldown-cmark = "0.13.0"
|
||||
pyo3 = { version = "0.25.1", features = ["extension-module", "abi3", "abi3-py39"] }
|
||||
rand = "0.9.1"
|
||||
rayon = "1.10.0"
|
||||
regex = "1.11.1"
|
||||
reqwest = { version = "0.12.20", default-features = false, features = ["json", "socks", "stream", "multipart"] }
|
||||
rusqlite = { version = "0.36.0", features = ["trace", "functions", "collation", "bundled"] }
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit b90ef6f03c251eb336029ac7c5f551200d41273f
|
||||
Subproject commit 939298f7c461407951988f362b1a08b451336a1e
|
|
@ -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
|
||||
# diagram (Deck options -> FSRS) showing the total number of
|
||||
# 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-simulate = Simulate
|
||||
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-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-plotted-on-x-axis = (Plotted on the X-axis)
|
||||
# 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
|
||||
# specific date.
|
||||
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 scheduler’s 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
|
|
@ -217,6 +217,8 @@ message DeckConfigsForUpdate {
|
|||
bool review_today_active = 5;
|
||||
// Whether new_today applies to today or a past day.
|
||||
bool new_today_active = 6;
|
||||
// Deck-specific desired retention override
|
||||
optional float desired_retention = 7;
|
||||
}
|
||||
string name = 1;
|
||||
int64 config_id = 2;
|
||||
|
|
|
@ -83,6 +83,8 @@ message Deck {
|
|||
optional uint32 new_limit = 7;
|
||||
DayLimit review_limit_today = 8;
|
||||
DayLimit new_limit_today = 9;
|
||||
// Deck-specific desired retention override
|
||||
optional float desired_retention = 10;
|
||||
|
||||
reserved 12 to 15;
|
||||
}
|
||||
|
|
|
@ -55,6 +55,8 @@ service SchedulerService {
|
|||
returns (ComputeOptimalRetentionResponse);
|
||||
rpc SimulateFsrsReview(SimulateFsrsReviewRequest)
|
||||
returns (SimulateFsrsReviewResponse);
|
||||
rpc SimulateFsrsWorkload(SimulateFsrsReviewRequest)
|
||||
returns (SimulateFsrsWorkloadResponse);
|
||||
rpc EvaluateParams(EvaluateParamsRequest) returns (EvaluateParamsResponse);
|
||||
rpc EvaluateParamsLegacy(EvaluateParamsLegacyRequest)
|
||||
returns (EvaluateParamsResponse);
|
||||
|
@ -414,6 +416,12 @@ message SimulateFsrsReviewResponse {
|
|||
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 {
|
||||
float optimal_retention = 1;
|
||||
}
|
||||
|
|
|
@ -133,6 +133,7 @@ class Card(DeprecatedNamesMixin):
|
|||
memory_state=self.memory_state,
|
||||
desired_retention=self.desired_retention,
|
||||
decay=self.decay,
|
||||
last_review_time_secs=self.last_review_time,
|
||||
)
|
||||
|
||||
@deprecated(info="please use col.update_card()")
|
||||
|
|
|
@ -73,7 +73,7 @@ langs = sorted(
|
|||
("ଓଡ଼ିଆ", "or_OR"),
|
||||
("Filipino", "tl"),
|
||||
("ئۇيغۇر", "ug"),
|
||||
("Oʻzbek", "uz_UZ"),
|
||||
("Oʻzbekcha", "uz_UZ"),
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
@ -51,6 +51,7 @@ class CardInfoDialog(QDialog):
|
|||
|
||||
def _setup_ui(self, card_id: CardId | None) -> None:
|
||||
self.mw.garbage_collect_on_dialog_finish(self)
|
||||
self.setMinimumSize(400, 300)
|
||||
disable_help_button(self)
|
||||
restoreGeom(self, self.GEOMETRY_KEY, default_size=(800, 800))
|
||||
add_close_shortcut(self)
|
||||
|
|
|
@ -654,6 +654,7 @@ exposed_backend_list = [
|
|||
"evaluate_params_legacy",
|
||||
"get_optimal_retention_parameters",
|
||||
"simulate_fsrs_review",
|
||||
"simulate_fsrs_workload",
|
||||
# DeckConfigService
|
||||
"get_ignored_before_count",
|
||||
"get_retention_workload",
|
||||
|
|
|
@ -633,7 +633,7 @@ class QtAudioInputRecorder(Recorder):
|
|||
from PyQt6.QtMultimedia import QAudioFormat, QAudioSource # type: ignore
|
||||
|
||||
format = QAudioFormat()
|
||||
format.setChannelCount(1)
|
||||
format.setChannelCount(2)
|
||||
format.setSampleRate(44100)
|
||||
format.setSampleFormat(QAudioFormat.SampleFormat.Int16)
|
||||
|
||||
|
|
|
@ -118,7 +118,7 @@ def sync_collection(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
|
|||
if out.new_endpoint:
|
||||
mw.pm.set_current_sync_url(out.new_endpoint)
|
||||
if out.server_message:
|
||||
showText(out.server_message)
|
||||
showText(out.server_message, parent=mw)
|
||||
if out.required == out.NO_CHANGES:
|
||||
tooltip(parent=mw, msg=tr.sync_collection_complete())
|
||||
# all done; track media progress
|
||||
|
|
|
@ -38,19 +38,19 @@ cp ../pyproject.toml "$APP_LAUNCHER/Contents/Resources/"
|
|||
cp ../../../.python-version "$APP_LAUNCHER/Contents/Resources/"
|
||||
cp ../versions.py "$APP_LAUNCHER/Contents/Resources/"
|
||||
|
||||
# Codesign
|
||||
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)
|
||||
# Codesign/bundle
|
||||
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"
|
||||
./dmg/build.sh "$OUTPUT_DIR"
|
||||
fi
|
|
@ -11,7 +11,6 @@ use std::time::SystemTime;
|
|||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use anki_io::copy_file;
|
||||
use anki_io::copy_if_newer;
|
||||
use anki_io::create_dir_all;
|
||||
use anki_io::modified_time;
|
||||
use anki_io::read_file;
|
||||
|
@ -50,6 +49,7 @@ struct State {
|
|||
pyproject_modified_by_user: bool,
|
||||
previous_version: Option<String>,
|
||||
resources_dir: std::path::PathBuf,
|
||||
venv_folder: std::path::PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
@ -111,6 +111,7 @@ fn run() -> Result<()> {
|
|||
pyproject_modified_by_user: false, // calculated later
|
||||
previous_version: None,
|
||||
resources_dir,
|
||||
venv_folder: uv_install_root.join(".venv"),
|
||||
};
|
||||
|
||||
// Check for uninstall request from Windows uninstaller
|
||||
|
@ -120,15 +121,11 @@ fn run() -> Result<()> {
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
// Create install directory and copy project files in
|
||||
// Create install directory
|
||||
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
|
||||
let pyproject_time = file_timestamp_secs(&state.user_pyproject_path);
|
||||
|
@ -158,7 +155,7 @@ fn run() -> Result<()> {
|
|||
|
||||
check_versions(&mut state);
|
||||
|
||||
let first_run = !state.uv_install_root.join(".venv").exists();
|
||||
let first_run = !state.venv_folder.exists();
|
||||
if first_run {
|
||||
handle_version_install_or_update(&state, MainMenuChoice::Latest)?;
|
||||
} else {
|
||||
|
@ -166,7 +163,7 @@ fn run() -> Result<()> {
|
|||
}
|
||||
|
||||
// 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")]
|
||||
{
|
||||
|
@ -192,13 +189,15 @@ fn run() -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_aqt_version(
|
||||
uv_path: &std::path::Path,
|
||||
uv_install_root: &std::path::Path,
|
||||
) -> Option<String> {
|
||||
let output = Command::new(uv_path)
|
||||
.current_dir(uv_install_root)
|
||||
.env("VIRTUAL_ENV", uv_install_root.join(".venv"))
|
||||
fn extract_aqt_version(state: &State) -> Option<String> {
|
||||
// Check if .venv exists first
|
||||
if !state.venv_folder.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let output = Command::new(&state.uv_path)
|
||||
.current_dir(&state.uv_install_root)
|
||||
.env("VIRTUAL_ENV", &state.venv_folder)
|
||||
.args(["pip", "show", "aqt"])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
@ -223,7 +222,7 @@ fn check_versions(state: &mut State) {
|
|||
}
|
||||
|
||||
// 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) => {
|
||||
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)?;
|
||||
|
||||
// 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
|
||||
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 = read_file(&state.user_python_version_path)?;
|
||||
|
@ -264,6 +263,11 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re
|
|||
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
|
||||
let mut command = Command::new(&state.uv_path);
|
||||
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
|
||||
.env("UV_CACHE_DIR", &state.uv_cache_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"]);
|
||||
|
||||
// Add python version if .python-version file exists
|
||||
|
@ -293,7 +315,7 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re
|
|||
Ok(_) => {
|
||||
// Sync succeeded
|
||||
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
|
||||
|
@ -364,9 +386,7 @@ fn main_menu_loop(state: &State) -> Result<()> {
|
|||
continue;
|
||||
}
|
||||
choice @ (MainMenuChoice::Latest | MainMenuChoice::Version(_)) => {
|
||||
if handle_version_install_or_update(state, choice.clone()).is_err() {
|
||||
continue;
|
||||
}
|
||||
handle_version_install_or_update(state, choice.clone())?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -374,12 +394,12 @@ fn main_menu_loop(state: &State) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn write_sync_marker(sync_complete_marker: &std::path::Path) -> Result<()> {
|
||||
fn write_sync_marker(state: &State) -> Result<()> {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.context("Failed to get system time")?
|
||||
.as_secs();
|
||||
write_file(sync_complete_marker, timestamp.to_string())?;
|
||||
write_file(&state.sync_complete_marker, timestamp.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -473,8 +493,6 @@ fn get_main_menu_choice(state: &State) -> Result<MainMenuChoice> {
|
|||
}
|
||||
|
||||
fn get_version_kind(state: &State) -> Result<Option<VersionKind>> {
|
||||
println!("Please wait...");
|
||||
|
||||
let releases = get_releases(state)?;
|
||||
let releases_str = releases
|
||||
.latest
|
||||
|
@ -633,15 +651,32 @@ fn fetch_versions(state: &State) -> Result<Vec<String>> {
|
|||
|
||||
let mut cmd = Command::new(&state.uv_path);
|
||||
cmd.current_dir(&state.uv_install_root)
|
||||
.args(["run", "--no-project"])
|
||||
.arg(&versions_script);
|
||||
.args(["run", "--no-project", "--no-config", "--managed-python"])
|
||||
.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")?;
|
||||
Ok(versions)
|
||||
}
|
||||
|
||||
fn get_releases(state: &State) -> Result<Releases> {
|
||||
println!("Checking for updates...");
|
||||
let include_prereleases = state.prerelease_marker.exists();
|
||||
let all_versions = fetch_versions(state)?;
|
||||
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()?;
|
||||
|
||||
if !addons21_path.exists() {
|
||||
|
@ -870,16 +905,24 @@ fn handle_uninstall(state: &State) -> Result<bool> {
|
|||
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> {
|
||||
let python_exe = if cfg!(target_os = "windows") {
|
||||
let show_console = std::env::var("ANKI_CONSOLE").is_ok();
|
||||
if show_console {
|
||||
state.uv_install_root.join(".venv/Scripts/python.exe")
|
||||
state.venv_folder.join("Scripts/python.exe")
|
||||
} else {
|
||||
state.uv_install_root.join(".venv/Scripts/pythonw.exe")
|
||||
state.venv_folder.join("Scripts/pythonw.exe")
|
||||
}
|
||||
} else {
|
||||
state.uv_install_root.join(".venv/bin/python")
|
||||
state.venv_folder.join("bin/python")
|
||||
};
|
||||
|
||||
let mut cmd = Command::new(&python_exe);
|
||||
|
|
|
@ -5,6 +5,10 @@ import json
|
|||
import sys
|
||||
import urllib.request
|
||||
|
||||
import pip_system_certs.wrapt_requests
|
||||
|
||||
pip_system_certs.wrapt_requests.inject_truststore()
|
||||
|
||||
|
||||
def main():
|
||||
"""Fetch and return all versions from PyPI, sorted by upload time."""
|
||||
|
|
|
@ -81,6 +81,7 @@ pin-project.workspace = true
|
|||
prost.workspace = true
|
||||
pulldown-cmark.workspace = true
|
||||
rand.workspace = true
|
||||
rayon.workspace = true
|
||||
regex.workspace = true
|
||||
reqwest.workspace = true
|
||||
rusqlite.workspace = true
|
||||
|
|
|
@ -11,6 +11,24 @@ use snafu::ensure;
|
|||
use snafu::ResultExt;
|
||||
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)]
|
||||
pub enum Error {
|
||||
#[snafu(display("Failed to execute: {cmdline}"))]
|
||||
|
@ -18,8 +36,15 @@ pub enum Error {
|
|||
cmdline: String,
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[snafu(display("Failed with code {code:?}: {cmdline}"))]
|
||||
ReturnedError { cmdline: String, code: Option<i32> },
|
||||
#[snafu(display("Failed to run ({code}): {cmdline}"))]
|
||||
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"))]
|
||||
InvalidUtf8 {
|
||||
cmdline: String,
|
||||
|
@ -71,31 +96,36 @@ impl CommandExt for Command {
|
|||
status.success(),
|
||||
ReturnedSnafu {
|
||||
cmdline: get_cmdline(self),
|
||||
code: status.code(),
|
||||
code: CodeDisplay::from(status.code()),
|
||||
}
|
||||
);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
fn utf8_output(&mut self) -> Result<Utf8Output> {
|
||||
let cmdline = get_cmdline(self);
|
||||
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!(
|
||||
output.status.success(),
|
||||
ReturnedSnafu {
|
||||
cmdline: get_cmdline(self),
|
||||
code: output.status.code(),
|
||||
ReturnedWithOutputSnafu {
|
||||
cmdline,
|
||||
code: CodeDisplay::from(output.status.code()),
|
||||
stdout: stdout.clone(),
|
||||
stderr: stderr.clone(),
|
||||
}
|
||||
);
|
||||
Ok(Utf8Output {
|
||||
stdout: String::from_utf8(output.stdout).with_context(|_| InvalidUtf8Snafu {
|
||||
cmdline: get_cmdline(self),
|
||||
})?,
|
||||
stderr: String::from_utf8(output.stderr).with_context(|_| InvalidUtf8Snafu {
|
||||
cmdline: get_cmdline(self),
|
||||
})?,
|
||||
})
|
||||
|
||||
Ok(Utf8Output { stdout, stderr })
|
||||
}
|
||||
|
||||
fn ensure_spawn(&mut self) -> Result<std::process::Child> {
|
||||
|
@ -135,7 +165,10 @@ mod test {
|
|||
#[cfg(not(windows))]
|
||||
assert!(matches!(
|
||||
Command::new("false").ensure_success(),
|
||||
Err(Error::ReturnedError { code: Some(1), .. })
|
||||
Err(Error::ReturnedError {
|
||||
code: CodeDisplay(_),
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -212,10 +212,13 @@ impl Collection {
|
|||
if fsrs_toggled {
|
||||
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()? {
|
||||
if let Ok(normal) = deck.normal() {
|
||||
let deck_id = deck.id;
|
||||
|
||||
if let Some(desired_retention) = normal.desired_retention {
|
||||
deck_desired_retention.insert(deck_id, desired_retention);
|
||||
}
|
||||
// previous order & params
|
||||
let previous_config_id = DeckConfigId(normal.config_id);
|
||||
let previous_config = configs_before_update.get(&previous_config_id);
|
||||
|
@ -277,10 +280,11 @@ impl Collection {
|
|||
if req.fsrs {
|
||||
Some(UpdateMemoryStateRequest {
|
||||
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,
|
||||
reschedule: req.fsrs_reschedule,
|
||||
historical_retention: c.inner.historical_retention,
|
||||
deck_desired_retention: deck_desired_retention.clone(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
|
@ -409,6 +413,7 @@ fn normal_deck_to_limits(deck: &NormalDeck, today: u32) -> Limits {
|
|||
.new_limit_today
|
||||
.map(|limit| limit.today == today)
|
||||
.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;
|
||||
update_day_limit(&mut deck.review_limit_today, limits.review_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) {
|
||||
|
|
|
@ -31,6 +31,7 @@ pub(crate) use name::immediate_parent_name;
|
|||
pub use name::NativeDeckName;
|
||||
pub use schema11::DeckSchema11;
|
||||
|
||||
use crate::deckconfig::DeckConfig;
|
||||
use crate::define_newtype;
|
||||
use crate::error::FilteredDeckError;
|
||||
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
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
|
|
@ -325,6 +325,7 @@ impl From<NormalDeckSchema11> for NormalDeck {
|
|||
new_limit: deck.new_limit,
|
||||
review_limit_today: deck.review_limit_today,
|
||||
new_limit_today: deck.new_limit_today,
|
||||
desired_retention: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -444,6 +444,8 @@ impl Collection {
|
|||
.get_deck(card.deck_id)?
|
||||
.or_not_found(card.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_next_states = if fsrs_enabled {
|
||||
let params = config.fsrs_params();
|
||||
|
@ -473,13 +475,13 @@ impl Collection {
|
|||
};
|
||||
Some(fsrs.next_states(
|
||||
card.memory_state.map(Into::into),
|
||||
config.inner.desired_retention,
|
||||
desired_retention,
|
||||
days_elapsed,
|
||||
)?)
|
||||
} else {
|
||||
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 =
|
||||
self.get_config_bool(BoolKey::FsrsShortTermWithStepsEnabled);
|
||||
let fsrs_allow_short_term = if fsrs_enabled {
|
||||
|
@ -662,6 +664,43 @@ pub(crate) mod test {
|
|||
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
|
||||
// state we applied to it
|
||||
#[test]
|
||||
|
|
|
@ -45,10 +45,11 @@ pub(crate) fn get_decay_from_params(params: &[f32]) -> f32 {
|
|||
#[derive(Debug)]
|
||||
pub(crate) struct UpdateMemoryStateRequest {
|
||||
pub params: Params,
|
||||
pub desired_retention: f32,
|
||||
pub preset_desired_retention: f32,
|
||||
pub historical_retention: f32,
|
||||
pub max_interval: u32,
|
||||
pub reschedule: bool,
|
||||
pub deck_desired_retention: HashMap<DeckId, f32>,
|
||||
}
|
||||
|
||||
pub(crate) struct UpdateMemoryStateEntry {
|
||||
|
@ -98,7 +99,8 @@ impl Collection {
|
|||
historical_retention.unwrap_or(0.9),
|
||||
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>();
|
||||
progress.update(false, |s| s.total_cards = items.len() as u32)?;
|
||||
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,
|
||||
// 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.
|
||||
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;
|
||||
if let Some(item) = item {
|
||||
card.set_memory_state(&fsrs, Some(item), historical_retention.unwrap())?;
|
||||
|
@ -132,7 +139,7 @@ impl Collection {
|
|||
let original_interval = card.interval;
|
||||
let interval = fsrs.next_interval(
|
||||
Some(state.stability),
|
||||
desired_retention.unwrap(),
|
||||
desired_retention,
|
||||
0,
|
||||
);
|
||||
card.interval = rescheduler
|
||||
|
@ -205,7 +212,11 @@ impl Collection {
|
|||
.storage
|
||||
.get_deck_config(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 params = config.fsrs_params();
|
||||
let decay = get_decay_from_params(params);
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
// 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 std::sync::Arc;
|
||||
|
||||
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::SimulateFsrsReviewResponse;
|
||||
use anki_proto::scheduler::SimulateFsrsWorkloadResponse;
|
||||
use fsrs::simulate;
|
||||
use fsrs::PostSchedulingFn;
|
||||
use fsrs::ReviewPriorityFn;
|
||||
|
@ -14,6 +16,8 @@ use fsrs::FSRS;
|
|||
use itertools::Itertools;
|
||||
use rand::rngs::StdRng;
|
||||
use rand::Rng;
|
||||
use rayon::iter::IntoParallelIterator;
|
||||
use rayon::iter::ParallelIterator;
|
||||
|
||||
use crate::card::CardQueue;
|
||||
use crate::card::CardType;
|
||||
|
@ -267,6 +271,38 @@ impl Collection {
|
|||
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 {
|
||||
|
|
|
@ -16,6 +16,7 @@ use anki_proto::scheduler::FuzzDeltaResponse;
|
|||
use anki_proto::scheduler::GetOptimalRetentionParametersResponse;
|
||||
use anki_proto::scheduler::SimulateFsrsReviewRequest;
|
||||
use anki_proto::scheduler::SimulateFsrsReviewResponse;
|
||||
use anki_proto::scheduler::SimulateFsrsWorkloadResponse;
|
||||
use fsrs::ComputeParametersInput;
|
||||
use fsrs::FSRSItem;
|
||||
use fsrs::FSRSReview;
|
||||
|
@ -283,6 +284,13 @@ impl crate::services::SchedulerService for Collection {
|
|||
self.simulate_review(input)
|
||||
}
|
||||
|
||||
fn simulate_fsrs_workload(
|
||||
&mut self,
|
||||
input: SimulateFsrsReviewRequest,
|
||||
) -> Result<SimulateFsrsWorkloadResponse> {
|
||||
self.simulate_workload(input)
|
||||
}
|
||||
|
||||
fn compute_optimal_retention(
|
||||
&mut self,
|
||||
input: SimulateFsrsReviewRequest,
|
||||
|
|
|
@ -12,14 +12,20 @@ impl Collection {
|
|||
.map(component_to_regex)
|
||||
.collect::<Result<_, _>>()?;
|
||||
let mut tags = vec![];
|
||||
let mut priority = vec![];
|
||||
self.storage.get_tags_by_predicate(|tag| {
|
||||
if tags.len() <= limit && filters_match(&filters, tag) {
|
||||
tags.push(tag.to_string());
|
||||
if priority.len() + tags.len() <= limit {
|
||||
match filters_match(&filters, tag) {
|
||||
Some(true) => priority.push(tag.to_string()),
|
||||
Some(_) => tags.push(tag.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// we only need the tag name
|
||||
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)
|
||||
}
|
||||
|
||||
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 is_prefix = true;
|
||||
'outer: for filter in filters {
|
||||
loop {
|
||||
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;
|
||||
} else {
|
||||
is_prefix = false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
Some(is_prefix)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
@ -50,28 +62,32 @@ mod test {
|
|||
#[test]
|
||||
fn matching() -> Result<()> {
|
||||
let filters = &[component_to_regex("b")?];
|
||||
assert!(filters_match(filters, "ABC"));
|
||||
assert!(filters_match(filters, "ABC::def"));
|
||||
assert!(filters_match(filters, "def::abc"));
|
||||
assert!(!filters_match(filters, "def"));
|
||||
assert!(filters_match(filters, "ABC").is_some());
|
||||
assert!(filters_match(filters, "ABC::def").is_some());
|
||||
assert!(filters_match(filters, "def::abc").is_some());
|
||||
assert!(filters_match(filters, "def").is_none());
|
||||
|
||||
let filters = &[component_to_regex("b")?, component_to_regex("E")?];
|
||||
assert!(!filters_match(filters, "ABC"));
|
||||
assert!(filters_match(filters, "ABC::def"));
|
||||
assert!(!filters_match(filters, "def::abc"));
|
||||
assert!(!filters_match(filters, "def"));
|
||||
assert!(filters_match(filters, "ABC").is_none());
|
||||
assert!(filters_match(filters, "ABC::def").is_some());
|
||||
assert!(filters_match(filters, "def::abc").is_none());
|
||||
assert!(filters_match(filters, "def").is_none());
|
||||
|
||||
let filters = &[
|
||||
component_to_regex("a")?,
|
||||
component_to_regex("c")?,
|
||||
component_to_regex("e")?,
|
||||
];
|
||||
assert!(!filters_match(filters, "ace"));
|
||||
assert!(!filters_match(filters, "a::c"));
|
||||
assert!(!filters_match(filters, "c::e"));
|
||||
assert!(filters_match(filters, "a::c::e"));
|
||||
assert!(filters_match(filters, "a::b::c::d::e"));
|
||||
assert!(filters_match(filters, "1::a::b::c::d::e::f"));
|
||||
assert!(filters_match(filters, "ace").is_none());
|
||||
assert!(filters_match(filters, "a::c").is_none());
|
||||
assert!(filters_match(filters, "c::e").is_none());
|
||||
assert!(filters_match(filters, "a::c::e").is_some());
|
||||
assert!(filters_match(filters, "a::b::c::d::e").is_some());
|
||||
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(())
|
||||
}
|
||||
|
|
|
@ -93,6 +93,10 @@ impl TimestampMillis {
|
|||
pub fn adding_secs(self, secs: i64) -> Self {
|
||||
Self(self.0 + secs * 1000)
|
||||
}
|
||||
|
||||
pub fn elapsed_millis(self) -> u64 {
|
||||
(Self::now().0 - self.0).max(0) as u64
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed() -> time::Duration {
|
||||
|
|
|
@ -24,6 +24,7 @@ export const HelpPage = {
|
|||
displayOrder: "https://docs.ankiweb.net/deck-options.html#display-order",
|
||||
maximumReviewsday: "https://docs.ankiweb.net/deck-options.html#maximum-reviewsday",
|
||||
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",
|
||||
audio: "https://docs.ankiweb.net/deck-options.html#audio",
|
||||
fsrs: "http://docs.ankiweb.net/deck-options.html#fsrs",
|
||||
|
|
|
@ -140,7 +140,7 @@
|
|||
applyAllParentLimits: {
|
||||
title: tr.deckConfigApplyAllParentLimits(),
|
||||
help: applyAllParentLimitsHelp,
|
||||
url: HelpPage.DeckOptions.newCardsday,
|
||||
url: HelpPage.DeckOptions.limitsFromTop,
|
||||
global: true,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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 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 Warning from "./Warning.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,
|
||||
} from "@generated/anki/deck_config_pb";
|
||||
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 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 fsrsReschedule = state.fsrsReschedule;
|
||||
const daysSinceLastOptimization = state.daysSinceLastOptimization;
|
||||
const limits = state.deckLimits;
|
||||
|
||||
$: lastOptimizationWarning =
|
||||
$daysSinceLastOptimization > 30 ? tr.deckConfigTimeToOptimize() : "";
|
||||
let desiredRetentionFocused = false;
|
||||
let desiredRetentionEverFocused = false;
|
||||
let optimized = false;
|
||||
const startingDesiredRetention = $config.desiredRetention.toFixed(2);
|
||||
$: if (desiredRetentionFocused) {
|
||||
desiredRetentionEverFocused = true;
|
||||
}
|
||||
|
@ -64,7 +67,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
|
||||
$: computing = computingParams || checkingParams;
|
||||
$: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`;
|
||||
$: roundedRetention = Number($config.desiredRetention.toFixed(2));
|
||||
$: roundedRetention = Number(effectiveDesiredRetention.toFixed(2));
|
||||
$: desiredRetentionWarning = getRetentionLongShortWarning(roundedRetention);
|
||||
|
||||
let desiredRetentionChangeInfo = "";
|
||||
|
@ -76,6 +79,29 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
|
||||
$: 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({
|
||||
params: fsrsParams($config),
|
||||
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 workloadModal: Modal;
|
||||
</script>
|
||||
|
||||
<SpinBoxFloatRow
|
||||
bind:value={$config.desiredRetention}
|
||||
defaultValue={defaults.desiredRetention}
|
||||
min={0.7}
|
||||
max={0.99}
|
||||
percentage={true}
|
||||
bind:focused={desiredRetentionFocused}
|
||||
<DynamicallySlottable slotHost={Item} api={{}}>
|
||||
<Item>
|
||||
<SpinBoxFloatRow
|
||||
bind:value={effectiveDesiredRetention}
|
||||
defaultValue={defaults.desiredRetention}
|
||||
min={0.7}
|
||||
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.deckConfigDesiredRetention()}
|
||||
</SettingTitle>
|
||||
</SpinBoxFloatRow>
|
||||
{tr.deckConfigFsrsDesiredRetentionHelpMeDecideExperimental()}
|
||||
</button>
|
||||
|
||||
<Warning warning={desiredRetentionChangeInfo} className={"alert-info two-line"} />
|
||||
<Warning warning={desiredRetentionWarning} className={retentionWarningClass} />
|
||||
|
@ -416,6 +462,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
{onPresetChange}
|
||||
/>
|
||||
|
||||
<SimulatorModal
|
||||
bind:modal={workloadModal}
|
||||
workload
|
||||
{state}
|
||||
{simulateFsrsRequest}
|
||||
{computing}
|
||||
{openHelpModal}
|
||||
{onPresetChange}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
margin-bottom: 0.375rem;
|
||||
|
|
|
@ -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 InputBox from "../graphs/InputBox.svelte";
|
||||
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 { renderSimulationChart } from "../graphs/simulator";
|
||||
import { computeOptimalRetention, simulateFsrsReview } from "@generated/backend";
|
||||
import { renderSimulationChart, renderWorkloadChart } from "../graphs/simulator";
|
||||
import {
|
||||
computeOptimalRetention,
|
||||
simulateFsrsReview,
|
||||
simulateFsrsWorkload,
|
||||
} from "@generated/backend";
|
||||
import { runWithBackendProgress } from "@tslib/progress";
|
||||
import type {
|
||||
ComputeOptimalRetentionResponse,
|
||||
SimulateFsrsReviewRequest,
|
||||
SimulateFsrsReviewResponse,
|
||||
SimulateFsrsWorkloadResponse,
|
||||
} from "@generated/anki/scheduler_pb";
|
||||
import type { DeckOptionsState } from "./lib";
|
||||
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 type { ComputeRetentionProgress } from "@generated/anki/collection_pb";
|
||||
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 simulateFsrsRequest: SimulateFsrsReviewRequest;
|
||||
export let computing: boolean;
|
||||
export let openHelpModal: (key: string) => void;
|
||||
export let onPresetChange: () => void;
|
||||
/** Do not modify this once set */
|
||||
export let workload: boolean = false;
|
||||
|
||||
const config = state.currentConfig;
|
||||
let simulateSubgraph: SimulateSubgraph = SimulateSubgraph.count;
|
||||
let simulateWorkloadSubgraph: SimulateWorkloadSubgraph =
|
||||
SimulateWorkloadSubgraph.ratio;
|
||||
let tableData: TableDatum[] = [];
|
||||
let simulating: boolean = false;
|
||||
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 simulationNumber = 0;
|
||||
let points: Point[] = [];
|
||||
let points: (WorkloadPoint | Point)[] = [];
|
||||
const newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
|
||||
let smooth = true;
|
||||
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() {
|
||||
points = points.filter((p) => p.label !== simulationNumber);
|
||||
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) {
|
||||
let pointsToRender = points;
|
||||
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,
|
||||
bounds,
|
||||
pointsToRender,
|
||||
simulateSubgraph,
|
||||
// This cast shouldn't matter because we aren't switching between modes in the same modal
|
||||
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-content">
|
||||
<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
|
||||
type="button"
|
||||
class="btn-close"
|
||||
|
@ -278,17 +359,38 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</SettingTitle>
|
||||
</SpinBoxRow>
|
||||
|
||||
<SpinBoxFloatRow
|
||||
bind:value={simulateFsrsRequest.desiredRetention}
|
||||
defaultValue={$config.desiredRetention}
|
||||
min={0.7}
|
||||
max={0.99}
|
||||
percentage={true}
|
||||
>
|
||||
<SettingTitle on:click={() => openHelpModal("desiredRetention")}>
|
||||
{tr.deckConfigDesiredRetention()}
|
||||
</SettingTitle>
|
||||
</SpinBoxFloatRow>
|
||||
{#if !workload}
|
||||
<SpinBoxFloatRow
|
||||
bind:value={simulateFsrsRequest.desiredRetention}
|
||||
defaultValue={$config.desiredRetention}
|
||||
min={0.7}
|
||||
max={0.99}
|
||||
percentage={true}
|
||||
>
|
||||
<SettingTitle
|
||||
on:click={() => openHelpModal("desiredRetention")}
|
||||
>
|
||||
{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
|
||||
bind:value={simulateFsrsRequest.newLimit}
|
||||
|
@ -421,79 +523,99 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
{/if}
|
||||
</details>
|
||||
</div>
|
||||
<button
|
||||
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
|
||||
disabled={computing}
|
||||
on:click={simulateFsrs}
|
||||
>
|
||||
{tr.deckConfigSimulate()}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
|
||||
disabled={computing}
|
||||
on:click={clearSimulation}
|
||||
>
|
||||
{tr.deckConfigClearLastSimulate()}
|
||||
</button>
|
||||
<div>
|
||||
<button
|
||||
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
|
||||
disabled={computing}
|
||||
on:click={workload ? simulateWorkload : simulateFsrs}
|
||||
>
|
||||
{tr.deckConfigSimulate()}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
|
||||
disabled={computing}
|
||||
on:click={() => {
|
||||
if (confirm(tr.deckConfigSaveOptionsToPresetConfirm())) {
|
||||
$config.newPerDay = simulateFsrsRequest.newLimit;
|
||||
$config.reviewsPerDay = simulateFsrsRequest.reviewLimit;
|
||||
$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>
|
||||
<button
|
||||
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
|
||||
disabled={computing}
|
||||
on:click={clearSimulation}
|
||||
>
|
||||
{tr.deckConfigClearLastSimulate()}
|
||||
</button>
|
||||
|
||||
{#if processing}
|
||||
{tr.actionsProcessing()}
|
||||
{/if}
|
||||
<button
|
||||
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
|
||||
disabled={computing}
|
||||
on:click={saveConfigToPreset}
|
||||
>
|
||||
{tr.deckConfigSaveOptionsToPreset()}
|
||||
</button>
|
||||
|
||||
{#if processing}
|
||||
{tr.actionsProcessing()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Graph>
|
||||
<div class="radio-group">
|
||||
<InputBox>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
value={SimulateSubgraph.count}
|
||||
bind:group={simulateSubgraph}
|
||||
/>
|
||||
{tr.deckConfigFsrsSimulatorRadioCount()}
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
value={SimulateSubgraph.time}
|
||||
bind:group={simulateSubgraph}
|
||||
/>
|
||||
{tr.statisticsReviewsTimeCheckbox()}
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
value={SimulateSubgraph.memorized}
|
||||
bind:group={simulateSubgraph}
|
||||
/>
|
||||
{tr.deckConfigFsrsSimulatorRadioMemorized()}
|
||||
</label>
|
||||
{#if !workload}
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
value={SimulateSubgraph.count}
|
||||
bind:group={simulateSubgraph}
|
||||
/>
|
||||
{tr.deckConfigFsrsSimulatorRadioCount()}
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
value={SimulateSubgraph.time}
|
||||
bind:group={simulateSubgraph}
|
||||
/>
|
||||
{tr.statisticsReviewsTimeCheckbox()}
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
value={SimulateSubgraph.memorized}
|
||||
bind:group={simulateSubgraph}
|
||||
/>
|
||||
{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>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -23,9 +23,12 @@
|
|||
<slot />
|
||||
</Col>
|
||||
<Col --col-size={6} breakpoint="xs">
|
||||
<ConfigInput>
|
||||
<SpinBox bind:value {min} {max} {step} {percentage} bind:focused />
|
||||
<RevertButton slot="revert" bind:value {defaultValue} />
|
||||
</ConfigInput>
|
||||
<Row class="flex-grow-1">
|
||||
<slot name="tabs" />
|
||||
<ConfigInput>
|
||||
<SpinBox bind:value {min} {max} {step} {percentage} bind:focused />
|
||||
<RevertButton slot="revert" bind:value {defaultValue} />
|
||||
</ConfigInput>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
|
@ -31,50 +31,86 @@ export interface Point {
|
|||
label: number;
|
||||
}
|
||||
|
||||
export type WorkloadPoint = Point & {
|
||||
learnSpan: number;
|
||||
};
|
||||
|
||||
export enum SimulateSubgraph {
|
||||
time,
|
||||
count,
|
||||
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(
|
||||
svgElem: SVGElement,
|
||||
bounds: GraphBounds,
|
||||
data: Point[],
|
||||
subgraph: SimulateSubgraph,
|
||||
): 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 convertedData = data.map(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 = ({
|
||||
[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 })),
|
||||
})[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 y = scaleLinear()
|
||||
.range([bounds.height - bounds.marginBottom, bounds.marginTop])
|
||||
|
@ -110,7 +230,7 @@ export function renderSimulationChart(
|
|||
.attr("fill", "currentColor");
|
||||
|
||||
// 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 color = schemeCategory10;
|
||||
|
@ -157,13 +277,6 @@ export function renderSimulationChart(
|
|||
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 {
|
||||
pointer(event, document.body);
|
||||
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);
|
||||
|
||||
const days = +((date.getTime() - Date.now()) / (60 * 60 * 24 * 1000)).toFixed();
|
||||
let tooltipContent = `Date: ${localizedDate(date)}<br>In ${days} Days<br>`;
|
||||
let tooltipContent = formatX(date);
|
||||
for (const [key, value] of Object.entries(groupData)) {
|
||||
const path = svg.select(`path[data-group="${key}"]`);
|
||||
const hidden = path.classed("hidden");
|
||||
|
@ -212,29 +324,6 @@ export function renderSimulationChart(
|
|||
.on("mousemove", legendMouseMove)
|
||||
.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")
|
||||
.attr("x", bounds.width - bounds.marginRight + 36)
|
||||
.attr("width", 12)
|
||||
|
|
|
@ -106,6 +106,9 @@ function initCanvas(): fabric.Canvas {
|
|||
fabric.Object.prototype.cornerStyle = "circle";
|
||||
fabric.Object.prototype.cornerStrokeColor = "#000000";
|
||||
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
|
||||
canvas.on("selection:created", () => {
|
||||
const g = canvas.getActiveObject();
|
||||
|
|
106
yarn.lock
106
yarn.lock
|
@ -2255,6 +2255,16 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 1.0.7
|
||||
resolution: "call-bind@npm:1.0.7"
|
||||
|
@ -3120,6 +3130,17 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 0.2.0
|
||||
resolution: "eastasianwidth@npm:0.2.0"
|
||||
|
@ -3241,6 +3262,13 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 1.3.0
|
||||
resolution: "es-errors@npm:1.3.0"
|
||||
|
@ -3264,6 +3292,15 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 2.0.3
|
||||
resolution: "es-set-tostringtag@npm:2.0.3"
|
||||
|
@ -3275,6 +3312,18 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 1.0.2
|
||||
resolution: "es-shim-unscopables@npm:1.0.2"
|
||||
|
@ -3947,13 +3996,15 @@ __metadata:
|
|||
linkType: hard
|
||||
|
||||
"form-data@npm:^4.0.0":
|
||||
version: 4.0.1
|
||||
resolution: "form-data@npm:4.0.1"
|
||||
version: 4.0.4
|
||||
resolution: "form-data@npm:4.0.4"
|
||||
dependencies:
|
||||
asynckit: "npm:^0.4.0"
|
||||
combined-stream: "npm:^1.0.8"
|
||||
es-set-tostringtag: "npm:^2.1.0"
|
||||
hasown: "npm:^2.0.2"
|
||||
mime-types: "npm:^2.1.12"
|
||||
checksum: 10c0/bb102d570be8592c23f4ea72d7df9daa50c7792eb0cf1c5d7e506c1706e7426a4e4ae48a35b109e91c85f1c0ec63774a21ae252b66f4eb981cb8efef7d0463c8
|
||||
checksum: 10c0/373525a9a034b9d57073e55eab79e501a714ffac02e7a9b01be1c820780652b16e4101819785e1e18f8d98f0aee866cc654d660a435c378e16a72f2e7cac9695
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -4031,6 +4082,34 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 1.0.2
|
||||
resolution: "get-symbol-description@npm:1.0.2"
|
||||
|
@ -4141,6 +4220,13 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 4.2.11
|
||||
resolution: "graceful-fs@npm:4.2.11"
|
||||
|
@ -4199,6 +4285,13 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 1.0.2
|
||||
resolution: "has-tostringtag@npm:1.0.2"
|
||||
|
@ -4920,6 +5013,13 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 3.2.2
|
||||
resolution: "mathjax@npm:3.2.2"
|
||||
|
|
Loading…
Reference in a new issue