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",
|
"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",
|
||||||
|
|
|
@ -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
|
|
@ -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 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.
|
## 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;
|
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;
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()")
|
||||||
|
|
|
@ -73,7 +73,7 @@ langs = sorted(
|
||||||
("ଓଡ଼ିଆ", "or_OR"),
|
("ଓଡ଼ିଆ", "or_OR"),
|
||||||
("Filipino", "tl"),
|
("Filipino", "tl"),
|
||||||
("ئۇيغۇر", "ug"),
|
("ئۇيغۇر", "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:
|
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)
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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);
|
||||||
|
|
|
@ -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."""
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(_),
|
||||||
|
..
|
||||||
|
})
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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)]
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
106
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue