mirror of
https://github.com/ankitects/anki.git
synced 2025-09-24 16:56:36 -04:00
Merge branch 'main' into break_answer_buttons_table_x_axis_in_two_rows
This commit is contained in:
commit
020f0d1bce
17 changed files with 2727 additions and 1433 deletions
|
@ -223,6 +223,7 @@ derivativeoflog7 <https://github.com/derivativeoflog7>
|
||||||
rreemmii-dev <https://github.com/rreemmii-dev>
|
rreemmii-dev <https://github.com/rreemmii-dev>
|
||||||
babofitos <https://github.com/babofitos>
|
babofitos <https://github.com/babofitos>
|
||||||
Jonathan Schoreels <https://github.com/JSchoreels>
|
Jonathan Schoreels <https://github.com/JSchoreels>
|
||||||
|
JL710
|
||||||
|
|
||||||
********************
|
********************
|
||||||
|
|
||||||
|
|
2642
Cargo.lock
generated
2642
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -35,9 +35,9 @@ git = "https://github.com/ankitects/linkcheck.git"
|
||||||
rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca"
|
rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca"
|
||||||
|
|
||||||
[workspace.dependencies.fsrs]
|
[workspace.dependencies.fsrs]
|
||||||
version = "3.0.0"
|
# version = "3.0.0"
|
||||||
# git = "https://github.com/open-spaced-repetition/fsrs-rs.git"
|
git = "https://github.com/open-spaced-repetition/fsrs-rs.git"
|
||||||
# rev = "c7717682997a8a6d53d97c7196281e745c5b3c8e"
|
rev = "092c20bac7d9239a991ae5b561556ad34c706c16"
|
||||||
# path = "../open-spaced-repetition/fsrs-rs"
|
# path = "../open-spaced-repetition/fsrs-rs"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
|
1125
cargo/licenses.json
1125
cargo/licenses.json
File diff suppressed because it is too large
Load diff
|
@ -1 +1 @@
|
||||||
Subproject commit 647e3cb0e697c51248201c4fb0df36514e765aa5
|
Subproject commit 376ae99eb47eae3c5e6298150162715423bc2e4e
|
|
@ -1 +1 @@
|
||||||
Subproject commit b3562ed3594d2afa0973edb877cc2f701ff162c3
|
Subproject commit 393bacec35703f91520e4d2feec37ed6953114f4
|
|
@ -40,6 +40,7 @@ qt-accel-layout-horizontal = &Horizontal
|
||||||
qt-accel-zoom-in = Zoom &In
|
qt-accel-zoom-in = Zoom &In
|
||||||
qt-accel-zoom-out = Zoom &Out
|
qt-accel-zoom-out = Zoom &Out
|
||||||
qt-accel-reset-zoom = &Reset Zoom
|
qt-accel-reset-zoom = &Reset Zoom
|
||||||
|
qt-accel-toggle-sidebar = Toggle Sidebar
|
||||||
qt-accel-zoom-editor-in = Zoom Editor &In
|
qt-accel-zoom-editor-in = Zoom Editor &In
|
||||||
qt-accel-zoom-editor-out = Zoom Editor &Out
|
qt-accel-zoom-editor-out = Zoom Editor &Out
|
||||||
qt-accel-create-backup = Create &Backup
|
qt-accel-create-backup = Create &Backup
|
||||||
|
|
|
@ -51,7 +51,7 @@ service SchedulerService {
|
||||||
returns (ComputeFsrsParamsResponse);
|
returns (ComputeFsrsParamsResponse);
|
||||||
rpc GetOptimalRetentionParameters(GetOptimalRetentionParametersRequest)
|
rpc GetOptimalRetentionParameters(GetOptimalRetentionParametersRequest)
|
||||||
returns (GetOptimalRetentionParametersResponse);
|
returns (GetOptimalRetentionParametersResponse);
|
||||||
rpc ComputeOptimalRetention(ComputeOptimalRetentionRequest)
|
rpc ComputeOptimalRetention(SimulateFsrsReviewRequest)
|
||||||
returns (ComputeOptimalRetentionResponse);
|
returns (ComputeOptimalRetentionResponse);
|
||||||
rpc SimulateFsrsReview(SimulateFsrsReviewRequest)
|
rpc SimulateFsrsReview(SimulateFsrsReviewRequest)
|
||||||
returns (SimulateFsrsReviewResponse);
|
returns (SimulateFsrsReviewResponse);
|
||||||
|
@ -409,16 +409,6 @@ message SimulateFsrsReviewResponse {
|
||||||
repeated float daily_time_cost = 4;
|
repeated float daily_time_cost = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ComputeOptimalRetentionRequest {
|
|
||||||
repeated float params = 1;
|
|
||||||
uint32 days_to_simulate = 2;
|
|
||||||
uint32 max_interval = 3;
|
|
||||||
string search = 4;
|
|
||||||
double loss_aversion = 5;
|
|
||||||
repeated float easy_days_percentages = 6;
|
|
||||||
optional uint32 suspend_after_lapse_count = 7;
|
|
||||||
}
|
|
||||||
|
|
||||||
message ComputeOptimalRetentionResponse {
|
message ComputeOptimalRetentionResponse {
|
||||||
float optimal_retention = 1;
|
float optimal_retention = 1;
|
||||||
}
|
}
|
||||||
|
|
|
@ -280,6 +280,19 @@ class Browser(QMainWindow):
|
||||||
if note_type_id := self.get_active_note_type_id():
|
if note_type_id := self.get_active_note_type_id():
|
||||||
add_cards.set_note_type(note_type_id)
|
add_cards.set_note_type(note_type_id)
|
||||||
|
|
||||||
|
# If in the Browser we open Preview and press Ctrl+W there,
|
||||||
|
# both Preview and Browser windows get closed by Qt out of the box.
|
||||||
|
# We circumvent that behavior by only closing the currently active window
|
||||||
|
def _handle_close(self):
|
||||||
|
active_window = QApplication.activeWindow()
|
||||||
|
if active_window and active_window != self:
|
||||||
|
if isinstance(active_window, QDialog):
|
||||||
|
active_window.reject()
|
||||||
|
else:
|
||||||
|
active_window.close()
|
||||||
|
else:
|
||||||
|
self.close()
|
||||||
|
|
||||||
def setupMenus(self) -> None:
|
def setupMenus(self) -> None:
|
||||||
# actions
|
# actions
|
||||||
f = self.form
|
f = self.form
|
||||||
|
@ -366,6 +379,7 @@ class Browser(QMainWindow):
|
||||||
qconnect(f.actionFind.triggered, self.onFind)
|
qconnect(f.actionFind.triggered, self.onFind)
|
||||||
qconnect(f.actionNote.triggered, self.onNote)
|
qconnect(f.actionNote.triggered, self.onNote)
|
||||||
qconnect(f.actionSidebar.triggered, self.focusSidebar)
|
qconnect(f.actionSidebar.triggered, self.focusSidebar)
|
||||||
|
qconnect(f.actionToggleSidebar.triggered, self.toggle_sidebar)
|
||||||
qconnect(f.actionCardList.triggered, self.onCardList)
|
qconnect(f.actionCardList.triggered, self.onCardList)
|
||||||
|
|
||||||
# help
|
# help
|
||||||
|
@ -695,7 +709,7 @@ class Browser(QMainWindow):
|
||||||
|
|
||||||
def setupSidebar(self) -> None:
|
def setupSidebar(self) -> None:
|
||||||
dw = self.sidebarDockWidget = QDockWidget(tr.browsing_sidebar(), self)
|
dw = self.sidebarDockWidget = QDockWidget(tr.browsing_sidebar(), self)
|
||||||
dw.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures)
|
dw.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetClosable)
|
||||||
dw.setObjectName("Sidebar")
|
dw.setObjectName("Sidebar")
|
||||||
dock_area = (
|
dock_area = (
|
||||||
Qt.DockWidgetArea.RightDockWidgetArea
|
Qt.DockWidgetArea.RightDockWidgetArea
|
||||||
|
@ -729,8 +743,11 @@ class Browser(QMainWindow):
|
||||||
# UI is more responsive
|
# UI is more responsive
|
||||||
self.mw.progress.timer(10, self.sidebar.refresh, False, parent=self.sidebar)
|
self.mw.progress.timer(10, self.sidebar.refresh, False, parent=self.sidebar)
|
||||||
|
|
||||||
def showSidebar(self) -> None:
|
def showSidebar(self, show: bool = True) -> None:
|
||||||
self.sidebarDockWidget.setVisible(True)
|
want_visible = not self.sidebarDockWidget.isVisible()
|
||||||
|
self.sidebarDockWidget.setVisible(show)
|
||||||
|
if want_visible and show:
|
||||||
|
self.sidebar.refresh()
|
||||||
|
|
||||||
def focusSidebar(self) -> None:
|
def focusSidebar(self) -> None:
|
||||||
self.showSidebar()
|
self.showSidebar()
|
||||||
|
@ -741,10 +758,7 @@ class Browser(QMainWindow):
|
||||||
self.sidebar.searchBar.setFocus()
|
self.sidebar.searchBar.setFocus()
|
||||||
|
|
||||||
def toggle_sidebar(self) -> None:
|
def toggle_sidebar(self) -> None:
|
||||||
want_visible = not self.sidebarDockWidget.isVisible()
|
self.showSidebar(not self.sidebarDockWidget.isVisible())
|
||||||
self.sidebarDockWidget.setVisible(want_visible)
|
|
||||||
if want_visible:
|
|
||||||
self.sidebar.refresh()
|
|
||||||
|
|
||||||
# legacy
|
# legacy
|
||||||
|
|
||||||
|
|
|
@ -317,6 +317,8 @@
|
||||||
<addaction name="separator"/>
|
<addaction name="separator"/>
|
||||||
<addaction name="actionFullScreen"/>
|
<addaction name="actionFullScreen"/>
|
||||||
<addaction name="separator"/>
|
<addaction name="separator"/>
|
||||||
|
<addaction name="actionToggleSidebar"/>
|
||||||
|
<addaction name="separator"/>
|
||||||
<addaction name="actionZoomIn"/>
|
<addaction name="actionZoomIn"/>
|
||||||
<addaction name="actionZoomOut"/>
|
<addaction name="actionZoomOut"/>
|
||||||
<addaction name="actionResetZoom"/>
|
<addaction name="actionResetZoom"/>
|
||||||
|
@ -702,6 +704,11 @@
|
||||||
<string>qt_accel_full_screen</string>
|
<string>qt_accel_full_screen</string>
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</action>
|
||||||
|
<action name="actionToggleSidebar">
|
||||||
|
<property name="text">
|
||||||
|
<string>qt_accel_toggle_sidebar</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
<action name="actionZoomIn">
|
<action name="actionZoomIn">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>qt_accel_zoom_editor_in</string>
|
<string>qt_accel_zoom_editor_in</string>
|
||||||
|
@ -785,7 +792,7 @@
|
||||||
<sender>actionClose</sender>
|
<sender>actionClose</sender>
|
||||||
<signal>triggered()</signal>
|
<signal>triggered()</signal>
|
||||||
<receiver>Dialog</receiver>
|
<receiver>Dialog</receiver>
|
||||||
<slot>close()</slot>
|
<slot>_handle_close()</slot>
|
||||||
<hints>
|
<hints>
|
||||||
<hint type="sourcelabel">
|
<hint type="sourcelabel">
|
||||||
<x>-1</x>
|
<x>-1</x>
|
||||||
|
|
|
@ -1,18 +1,12 @@
|
||||||
// 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::sync::Arc;
|
use anki_proto::scheduler::SimulateFsrsReviewRequest;
|
||||||
|
|
||||||
use anki_proto::scheduler::ComputeOptimalRetentionRequest;
|
|
||||||
use fsrs::extract_simulator_config;
|
use fsrs::extract_simulator_config;
|
||||||
use fsrs::PostSchedulingFn;
|
|
||||||
use fsrs::SimulatorConfig;
|
use fsrs::SimulatorConfig;
|
||||||
use fsrs::FSRS;
|
use fsrs::FSRS;
|
||||||
|
|
||||||
use super::simulator::apply_load_balance_and_easy_days;
|
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use crate::revlog::RevlogEntry;
|
use crate::revlog::RevlogEntry;
|
||||||
use crate::scheduler::states::load_balancer::parse_easy_days_percentages;
|
|
||||||
use crate::search::SortMode;
|
|
||||||
|
|
||||||
#[derive(Default, Clone, Copy, Debug)]
|
#[derive(Default, Clone, Copy, Debug)]
|
||||||
pub struct ComputeRetentionProgress {
|
pub struct ComputeRetentionProgress {
|
||||||
|
@ -21,65 +15,16 @@ pub struct ComputeRetentionProgress {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Collection {
|
impl Collection {
|
||||||
pub fn compute_optimal_retention(
|
pub fn compute_optimal_retention(&mut self, req: SimulateFsrsReviewRequest) -> Result<f32> {
|
||||||
&mut self,
|
|
||||||
req: ComputeOptimalRetentionRequest,
|
|
||||||
) -> Result<f32> {
|
|
||||||
let mut anki_progress = self.new_progress_handler::<ComputeRetentionProgress>();
|
let mut anki_progress = self.new_progress_handler::<ComputeRetentionProgress>();
|
||||||
let fsrs = FSRS::new(None)?;
|
let fsrs = FSRS::new(None)?;
|
||||||
if req.days_to_simulate == 0 {
|
if req.days_to_simulate == 0 {
|
||||||
invalid_input!("no days to simulate")
|
invalid_input!("no days to simulate")
|
||||||
}
|
}
|
||||||
let revlogs = self
|
let (config, cards) = self.simulate_request_to_config(&req)?;
|
||||||
.search_cards_into_table(&req.search, SortMode::NoOrder)?
|
|
||||||
.col
|
|
||||||
.storage
|
|
||||||
.get_revlog_entries_for_searched_cards_in_card_order()?;
|
|
||||||
let p = self.get_optimal_retention_parameters(revlogs)?;
|
|
||||||
let learn_span = req.days_to_simulate as usize;
|
|
||||||
let learn_limit = 10;
|
|
||||||
let deck_size = learn_span * learn_limit;
|
|
||||||
let easy_days_percentages = parse_easy_days_percentages(req.easy_days_percentages)?;
|
|
||||||
let next_day_at = self.timing_today()?.next_day_at;
|
|
||||||
let post_scheduling_fn: Option<PostSchedulingFn> =
|
|
||||||
if self.get_config_bool(BoolKey::LoadBalancerEnabled) {
|
|
||||||
Some(PostSchedulingFn(Arc::new(
|
|
||||||
move |card, max_interval, today, due_cnt_per_day, rng| {
|
|
||||||
apply_load_balance_and_easy_days(
|
|
||||||
card.interval,
|
|
||||||
max_interval,
|
|
||||||
today,
|
|
||||||
due_cnt_per_day,
|
|
||||||
rng,
|
|
||||||
next_day_at,
|
|
||||||
&easy_days_percentages,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
Ok(fsrs
|
Ok(fsrs
|
||||||
.optimal_retention(
|
.optimal_retention(
|
||||||
&SimulatorConfig {
|
&config,
|
||||||
deck_size,
|
|
||||||
learn_span: req.days_to_simulate as usize,
|
|
||||||
max_cost_perday: f32::MAX,
|
|
||||||
max_ivl: req.max_interval as f32,
|
|
||||||
first_rating_prob: p.first_rating_prob,
|
|
||||||
review_rating_prob: p.review_rating_prob,
|
|
||||||
learn_limit,
|
|
||||||
review_limit: usize::MAX,
|
|
||||||
new_cards_ignore_review_limit: true,
|
|
||||||
suspend_after_lapses: None,
|
|
||||||
post_scheduling_fn,
|
|
||||||
review_priority_fn: None,
|
|
||||||
learning_step_transitions: p.learning_step_transitions,
|
|
||||||
relearning_step_transitions: p.relearning_step_transitions,
|
|
||||||
state_rating_costs: p.state_rating_costs,
|
|
||||||
learning_step_count: p.learning_step_count,
|
|
||||||
relearning_step_count: p.relearning_step_count,
|
|
||||||
},
|
|
||||||
&req.params,
|
&req.params,
|
||||||
|ip| {
|
|ip| {
|
||||||
anki_progress
|
anki_progress
|
||||||
|
@ -88,6 +33,7 @@ impl Collection {
|
||||||
})
|
})
|
||||||
.is_ok()
|
.is_ok()
|
||||||
},
|
},
|
||||||
|
Some(cards),
|
||||||
)?
|
)?
|
||||||
.clamp(0.7, 0.95))
|
.clamp(0.7, 0.95))
|
||||||
}
|
}
|
||||||
|
|
|
@ -75,7 +75,6 @@ pub(crate) fn apply_load_balance_and_easy_days(
|
||||||
fn create_review_priority_fn(
|
fn create_review_priority_fn(
|
||||||
review_order: ReviewCardOrder,
|
review_order: ReviewCardOrder,
|
||||||
deck_size: usize,
|
deck_size: usize,
|
||||||
params: Vec<f32>,
|
|
||||||
) -> Option<ReviewPriorityFn> {
|
) -> Option<ReviewPriorityFn> {
|
||||||
// Helper macro to wrap closure in ReviewPriorityFn
|
// Helper macro to wrap closure in ReviewPriorityFn
|
||||||
macro_rules! wrap {
|
macro_rules! wrap {
|
||||||
|
@ -86,28 +85,28 @@ fn create_review_priority_fn(
|
||||||
|
|
||||||
match review_order {
|
match review_order {
|
||||||
// Ease-based ordering
|
// Ease-based ordering
|
||||||
EaseAscending => wrap!(|c| -(c.difficulty * 100.0) as i32),
|
EaseAscending => wrap!(|c, _w| -(c.difficulty * 100.0) as i32),
|
||||||
EaseDescending => wrap!(|c| (c.difficulty * 100.0) as i32),
|
EaseDescending => wrap!(|c, _w| (c.difficulty * 100.0) as i32),
|
||||||
|
|
||||||
// Interval-based ordering
|
// Interval-based ordering
|
||||||
IntervalsAscending => wrap!(|c| c.interval as i32),
|
IntervalsAscending => wrap!(|c, _w| c.interval as i32),
|
||||||
IntervalsDescending => wrap!(|c| -(c.interval as i32)),
|
IntervalsDescending => wrap!(|c, _w| -(c.interval as i32)),
|
||||||
// Retrievability-based ordering
|
// Retrievability-based ordering
|
||||||
RetrievabilityAscending => {
|
RetrievabilityAscending => {
|
||||||
wrap!(move |c| (c.retrievability(¶ms) * 1000.0) as i32)
|
wrap!(move |c, w| (c.retrievability(w) * 1000.0) as i32)
|
||||||
}
|
}
|
||||||
RetrievabilityDescending => {
|
RetrievabilityDescending => {
|
||||||
wrap!(move |c| -(c.retrievability(¶ms) * 1000.0) as i32)
|
wrap!(move |c, w| -(c.retrievability(w) * 1000.0) as i32)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Due date ordering
|
// Due date ordering
|
||||||
Day | DayThenDeck | DeckThenDay => {
|
Day | DayThenDeck | DeckThenDay => {
|
||||||
wrap!(|c| c.scheduled_due() as i32)
|
wrap!(|c, _w| c.scheduled_due() as i32)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Random ordering
|
// Random ordering
|
||||||
Random => {
|
Random => {
|
||||||
wrap!(move |_| rand::thread_rng().gen_range(0..deck_size) as i32)
|
wrap!(move |_c, _w| rand::thread_rng().gen_range(0..deck_size) as i32)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not implemented yet
|
// Not implemented yet
|
||||||
|
@ -116,10 +115,10 @@ fn create_review_priority_fn(
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Collection {
|
impl Collection {
|
||||||
pub fn simulate_review(
|
pub fn simulate_request_to_config(
|
||||||
&mut self,
|
&mut self,
|
||||||
req: SimulateFsrsReviewRequest,
|
req: &SimulateFsrsReviewRequest,
|
||||||
) -> Result<SimulateFsrsReviewResponse> {
|
) -> Result<(SimulatorConfig, Vec<fsrs::Card>)> {
|
||||||
let guard = self.search_cards_into_table(&req.search, SortMode::NoOrder)?;
|
let guard = self.search_cards_into_table(&req.search, SortMode::NoOrder)?;
|
||||||
let revlogs = guard
|
let revlogs = guard
|
||||||
.col
|
.col
|
||||||
|
@ -171,7 +170,7 @@ impl Collection {
|
||||||
let deck_size = converted_cards.len();
|
let deck_size = converted_cards.len();
|
||||||
let p = self.get_optimal_retention_parameters(revlogs)?;
|
let p = self.get_optimal_retention_parameters(revlogs)?;
|
||||||
|
|
||||||
let easy_days_percentages = parse_easy_days_percentages(req.easy_days_percentages)?;
|
let easy_days_percentages = parse_easy_days_percentages(&req.easy_days_percentages)?;
|
||||||
let next_day_at = self.timing_today()?.next_day_at;
|
let next_day_at = self.timing_today()?.next_day_at;
|
||||||
|
|
||||||
let post_scheduling_fn: Option<PostSchedulingFn> =
|
let post_scheduling_fn: Option<PostSchedulingFn> =
|
||||||
|
@ -197,7 +196,7 @@ impl Collection {
|
||||||
.review_order
|
.review_order
|
||||||
.try_into()
|
.try_into()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|order| create_review_priority_fn(order, deck_size, req.params.clone()));
|
.and_then(|order| create_review_priority_fn(order, deck_size));
|
||||||
|
|
||||||
let config = SimulatorConfig {
|
let config = SimulatorConfig {
|
||||||
deck_size,
|
deck_size,
|
||||||
|
@ -218,12 +217,21 @@ impl Collection {
|
||||||
learning_step_count: p.learning_step_count,
|
learning_step_count: p.learning_step_count,
|
||||||
relearning_step_count: p.relearning_step_count,
|
relearning_step_count: p.relearning_step_count,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Ok((config, converted_cards))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn simulate_review(
|
||||||
|
&mut self,
|
||||||
|
req: SimulateFsrsReviewRequest,
|
||||||
|
) -> Result<SimulateFsrsReviewResponse> {
|
||||||
|
let (config, cards) = self.simulate_request_to_config(&req)?;
|
||||||
let result = simulate(
|
let result = simulate(
|
||||||
&config,
|
&config,
|
||||||
&req.params,
|
&req.params,
|
||||||
req.desired_retention,
|
req.desired_retention,
|
||||||
None,
|
None,
|
||||||
Some(converted_cards),
|
Some(cards),
|
||||||
)?;
|
)?;
|
||||||
Ok(SimulateFsrsReviewResponse {
|
Ok(SimulateFsrsReviewResponse {
|
||||||
accumulated_knowledge_acquisition: result.memorized_cnt_per_day,
|
accumulated_knowledge_acquisition: result.memorized_cnt_per_day,
|
||||||
|
|
|
@ -9,7 +9,6 @@ use anki_proto::generic;
|
||||||
use anki_proto::scheduler;
|
use anki_proto::scheduler;
|
||||||
use anki_proto::scheduler::ComputeFsrsParamsResponse;
|
use anki_proto::scheduler::ComputeFsrsParamsResponse;
|
||||||
use anki_proto::scheduler::ComputeMemoryStateResponse;
|
use anki_proto::scheduler::ComputeMemoryStateResponse;
|
||||||
use anki_proto::scheduler::ComputeOptimalRetentionRequest;
|
|
||||||
use anki_proto::scheduler::ComputeOptimalRetentionResponse;
|
use anki_proto::scheduler::ComputeOptimalRetentionResponse;
|
||||||
use anki_proto::scheduler::FsrsBenchmarkResponse;
|
use anki_proto::scheduler::FsrsBenchmarkResponse;
|
||||||
use anki_proto::scheduler::FuzzDeltaRequest;
|
use anki_proto::scheduler::FuzzDeltaRequest;
|
||||||
|
@ -284,7 +283,7 @@ impl crate::services::SchedulerService for Collection {
|
||||||
|
|
||||||
fn compute_optimal_retention(
|
fn compute_optimal_retention(
|
||||||
&mut self,
|
&mut self,
|
||||||
input: ComputeOptimalRetentionRequest,
|
input: SimulateFsrsReviewRequest,
|
||||||
) -> Result<ComputeOptimalRetentionResponse> {
|
) -> Result<ComputeOptimalRetentionResponse> {
|
||||||
Ok(ComputeOptimalRetentionResponse {
|
Ok(ComputeOptimalRetentionResponse {
|
||||||
optimal_retention: self.compute_optimal_retention(input)?,
|
optimal_retention: self.compute_optimal_retention(input)?,
|
||||||
|
|
|
@ -277,7 +277,7 @@ impl LoadBalancer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn parse_easy_days_percentages(percentages: Vec<f32>) -> Result<[EasyDay; 7]> {
|
pub(crate) fn parse_easy_days_percentages(percentages: &[f32]) -> Result<[EasyDay; 7]> {
|
||||||
if percentages.is_empty() {
|
if percentages.is_empty() {
|
||||||
return Ok([EasyDay::Normal; 7]);
|
return Ok([EasyDay::Normal; 7]);
|
||||||
}
|
}
|
||||||
|
@ -300,7 +300,7 @@ pub(crate) fn build_easy_days_percentages(
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(dcid, conf)| {
|
.map(|(dcid, conf)| {
|
||||||
let easy_days_percentages =
|
let easy_days_percentages =
|
||||||
parse_easy_days_percentages(conf.inner.easy_days_percentages)?;
|
parse_easy_days_percentages(&conf.inner.easy_days_percentages)?;
|
||||||
Ok((dcid, easy_days_percentages))
|
Ok((dcid, easy_days_percentages))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|
|
@ -326,7 +326,7 @@ fn add_extract_fsrs_retrievability(db: &Connection) -> rusqlite::Result<()> {
|
||||||
let review_day = due.saturating_sub(ivl);
|
let review_day = due.saturating_sub(ivl);
|
||||||
days_elapsed.saturating_sub(review_day) as u32
|
days_elapsed.saturating_sub(review_day) as u32
|
||||||
};
|
};
|
||||||
let decay = card_data.decay.unwrap_or_default();
|
let decay = card_data.decay.unwrap_or(FSRS5_DEFAULT_DECAY);
|
||||||
Ok(card_data.memory_state().map(|state| {
|
Ok(card_data.memory_state().map(|state| {
|
||||||
FSRS::new(None)
|
FSRS::new(None)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|
|
@ -7,13 +7,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
ComputeRetentionProgress,
|
ComputeRetentionProgress,
|
||||||
type ComputeParamsProgress,
|
type ComputeParamsProgress,
|
||||||
} from "@generated/anki/collection_pb";
|
} from "@generated/anki/collection_pb";
|
||||||
import {
|
import { SimulateFsrsReviewRequest } from "@generated/anki/scheduler_pb";
|
||||||
ComputeOptimalRetentionRequest,
|
|
||||||
SimulateFsrsReviewRequest,
|
|
||||||
} from "@generated/anki/scheduler_pb";
|
|
||||||
import {
|
import {
|
||||||
computeFsrsParams,
|
computeFsrsParams,
|
||||||
computeOptimalRetention,
|
|
||||||
evaluateParams,
|
evaluateParams,
|
||||||
setWantsAbort,
|
setWantsAbort,
|
||||||
} from "@generated/backend";
|
} from "@generated/backend";
|
||||||
|
@ -26,7 +22,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import GlobalLabel from "./GlobalLabel.svelte";
|
import GlobalLabel from "./GlobalLabel.svelte";
|
||||||
import { commitEditing, fsrsParams, type DeckOptionsState } from "./lib";
|
import { commitEditing, fsrsParams, type DeckOptionsState } from "./lib";
|
||||||
import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte";
|
import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte";
|
||||||
import SpinBoxRow from "./SpinBoxRow.svelte";
|
|
||||||
import Warning from "./Warning.svelte";
|
import Warning from "./Warning.svelte";
|
||||||
import ParamsInputRow from "./ParamsInputRow.svelte";
|
import ParamsInputRow from "./ParamsInputRow.svelte";
|
||||||
import ParamsSearchRow from "./ParamsSearchRow.svelte";
|
import ParamsSearchRow from "./ParamsSearchRow.svelte";
|
||||||
|
@ -37,8 +32,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
export let openHelpModal: (String) => void;
|
export let openHelpModal: (String) => void;
|
||||||
export let onPresetChange: () => void;
|
export let onPresetChange: () => void;
|
||||||
|
|
||||||
const presetName = state.currentPresetName;
|
|
||||||
|
|
||||||
const config = state.currentConfig;
|
const config = state.currentConfig;
|
||||||
const defaults = state.defaults;
|
const defaults = state.defaults;
|
||||||
const fsrsReschedule = state.fsrsReschedule;
|
const fsrsReschedule = state.fsrsReschedule;
|
||||||
|
@ -50,13 +43,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
let computeParamsProgress: ComputeParamsProgress | undefined;
|
let computeParamsProgress: ComputeParamsProgress | undefined;
|
||||||
let computingParams = false;
|
let computingParams = false;
|
||||||
let checkingParams = false;
|
let checkingParams = false;
|
||||||
let computingRetention = false;
|
|
||||||
|
|
||||||
let optimalRetention = 0;
|
$: computing = computingParams || checkingParams;
|
||||||
$: if ($presetName) {
|
|
||||||
optimalRetention = 0;
|
|
||||||
}
|
|
||||||
$: computing = computingParams || checkingParams || computingRetention;
|
|
||||||
$: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`;
|
$: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`;
|
||||||
$: roundedRetention = Number($config.desiredRetention.toFixed(2));
|
$: roundedRetention = Number($config.desiredRetention.toFixed(2));
|
||||||
$: desiredRetentionWarning = getRetentionWarning(
|
$: desiredRetentionWarning = getRetentionWarning(
|
||||||
|
@ -65,19 +53,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
);
|
);
|
||||||
$: retentionWarningClass = getRetentionWarningClass(roundedRetention);
|
$: retentionWarningClass = getRetentionWarningClass(roundedRetention);
|
||||||
|
|
||||||
let computeRetentionProgress:
|
|
||||||
| ComputeParamsProgress
|
|
||||||
| ComputeRetentionProgress
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
const optimalRetentionRequest = new ComputeOptimalRetentionRequest({
|
|
||||||
daysToSimulate: 365,
|
|
||||||
lossAversion: 2.5,
|
|
||||||
});
|
|
||||||
$: if (optimalRetentionRequest.daysToSimulate > 3650) {
|
|
||||||
optimalRetentionRequest.daysToSimulate = 3650;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
|
$: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
|
||||||
|
|
||||||
$: simulateFsrsRequest = new SimulateFsrsReviewRequest({
|
$: simulateFsrsRequest = new SimulateFsrsReviewRequest({
|
||||||
|
@ -233,44 +208,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function computeRetention(): Promise<void> {
|
|
||||||
if (computingRetention) {
|
|
||||||
await setWantsAbort({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.presetAssignmentsChanged()) {
|
|
||||||
alert(tr.deckConfigPleaseSaveYourChangesFirst());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
computingRetention = true;
|
|
||||||
computeRetentionProgress = undefined;
|
|
||||||
try {
|
|
||||||
await runWithBackendProgress(
|
|
||||||
async () => {
|
|
||||||
optimalRetentionRequest.maxInterval = $config.maximumReviewInterval;
|
|
||||||
optimalRetentionRequest.params = fsrsParams($config);
|
|
||||||
optimalRetentionRequest.search = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`;
|
|
||||||
optimalRetentionRequest.easyDaysPercentages =
|
|
||||||
$config.easyDaysPercentages;
|
|
||||||
const resp = await computeOptimalRetention(optimalRetentionRequest);
|
|
||||||
optimalRetention = resp.optimalRetention;
|
|
||||||
computeRetentionProgress = undefined;
|
|
||||||
},
|
|
||||||
(progress) => {
|
|
||||||
if (progress.value.case === "computeRetention") {
|
|
||||||
computeRetentionProgress = progress.value.value;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
computingRetention = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: computeParamsProgressString = renderWeightProgress(computeParamsProgress);
|
$: computeParamsProgressString = renderWeightProgress(computeParamsProgress);
|
||||||
$: computeRetentionProgressString = renderRetentionProgress(
|
|
||||||
computeRetentionProgress,
|
|
||||||
);
|
|
||||||
$: totalReviews = computeParamsProgress?.reviews ?? undefined;
|
$: totalReviews = computeParamsProgress?.reviews ?? undefined;
|
||||||
|
|
||||||
function renderWeightProgress(val: ComputeParamsProgress | undefined): String {
|
function renderWeightProgress(val: ComputeParamsProgress | undefined): String {
|
||||||
|
@ -285,22 +223,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRetentionProgress(
|
|
||||||
val: ComputeRetentionProgress | undefined,
|
|
||||||
): String {
|
|
||||||
if (!val) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return tr.deckConfigIterations({ count: val.current });
|
|
||||||
}
|
|
||||||
|
|
||||||
function estimatedRetention(retention: number): String {
|
|
||||||
if (!retention) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return tr.deckConfigPredictedOptimalRetention({ num: retention.toFixed(2) });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function computeAllParams(): Promise<void> {
|
async function computeAllParams(): Promise<void> {
|
||||||
await commitEditing();
|
await commitEditing();
|
||||||
state.save(UpdateDeckConfigsMode.COMPUTE_ALL_PARAMS);
|
state.save(UpdateDeckConfigsMode.COMPUTE_ALL_PARAMS);
|
||||||
|
@ -390,49 +312,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="m-2">
|
|
||||||
<details>
|
|
||||||
<summary>{tr.deckConfigComputeOptimalRetention()}</summary>
|
|
||||||
|
|
||||||
<SpinBoxRow
|
|
||||||
bind:value={optimalRetentionRequest.daysToSimulate}
|
|
||||||
defaultValue={365}
|
|
||||||
min={1}
|
|
||||||
max={3650}
|
|
||||||
>
|
|
||||||
<SettingTitle on:click={() => openHelpModal("computeOptimalRetention")}>
|
|
||||||
{tr.deckConfigDaysToSimulate()}
|
|
||||||
</SettingTitle>
|
|
||||||
</SpinBoxRow>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="btn {computingRetention ? 'btn-warning' : 'btn-primary'}"
|
|
||||||
disabled={!computingRetention && computing}
|
|
||||||
on:click={() => computeRetention()}
|
|
||||||
>
|
|
||||||
{#if computingRetention}
|
|
||||||
{tr.actionsCancel()}
|
|
||||||
{:else}
|
|
||||||
{tr.deckConfigComputeButton()}
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if optimalRetention}
|
|
||||||
{estimatedRetention(optimalRetention)}
|
|
||||||
{#if optimalRetention - $config.desiredRetention >= 0.01}
|
|
||||||
<Warning
|
|
||||||
warning={tr.deckConfigDesiredRetentionBelowOptimal()}
|
|
||||||
className="alert-warning"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if computingRetention}
|
|
||||||
<div>{computeRetentionProgressString}</div>
|
|
||||||
{/if}
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="m-2">
|
<div class="m-2">
|
||||||
<button class="btn btn-primary" on:click={() => (showSimulator = true)}>
|
<button class="btn btn-primary" on:click={() => (showSimulator = true)}>
|
||||||
{tr.deckConfigFsrsSimulatorExperimental()}
|
{tr.deckConfigFsrsSimulatorExperimental()}
|
||||||
|
|
|
@ -16,9 +16,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import { SimulateSubgraph, type Point } from "../graphs/simulator";
|
import { SimulateSubgraph, type Point } from "../graphs/simulator";
|
||||||
import * as tr from "@generated/ftl";
|
import * as tr from "@generated/ftl";
|
||||||
import { renderSimulationChart } from "../graphs/simulator";
|
import { renderSimulationChart } from "../graphs/simulator";
|
||||||
import { simulateFsrsReview } from "@generated/backend";
|
import { computeOptimalRetention, simulateFsrsReview } from "@generated/backend";
|
||||||
import { runWithBackendProgress } from "@tslib/progress";
|
import { runWithBackendProgress } from "@tslib/progress";
|
||||||
import type {
|
import type {
|
||||||
|
ComputeOptimalRetentionResponse,
|
||||||
SimulateFsrsReviewRequest,
|
SimulateFsrsReviewRequest,
|
||||||
SimulateFsrsReviewResponse,
|
SimulateFsrsReviewResponse,
|
||||||
} from "@generated/anki/scheduler_pb";
|
} from "@generated/anki/scheduler_pb";
|
||||||
|
@ -30,6 +31,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import EnumSelectorRow from "$lib/components/EnumSelectorRow.svelte";
|
import EnumSelectorRow from "$lib/components/EnumSelectorRow.svelte";
|
||||||
import { DeckConfig_Config_LeechAction } from "@generated/anki/deck_config_pb";
|
import { DeckConfig_Config_LeechAction } from "@generated/anki/deck_config_pb";
|
||||||
import EasyDaysInput from "./EasyDaysInput.svelte";
|
import EasyDaysInput from "./EasyDaysInput.svelte";
|
||||||
|
import Warning from "./Warning.svelte";
|
||||||
|
import type { ComputeRetentionProgress } from "@generated/anki/collection_pb";
|
||||||
|
|
||||||
export let shown = false;
|
export let shown = false;
|
||||||
export let state: DeckOptionsState;
|
export let state: DeckOptionsState;
|
||||||
|
@ -53,9 +56,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
let suspendLeeches = $config.leechAction == DeckConfig_Config_LeechAction.SUSPEND;
|
let suspendLeeches = $config.leechAction == DeckConfig_Config_LeechAction.SUSPEND;
|
||||||
let leechThreshold = $config.leechThreshold;
|
let leechThreshold = $config.leechThreshold;
|
||||||
|
|
||||||
|
let optimalRetention: null | number = null;
|
||||||
|
let computingRetention = false;
|
||||||
|
let computeRetentionProgress: ComputeRetentionProgress | undefined = undefined;
|
||||||
|
|
||||||
$: daysToSimulate = 365;
|
$: daysToSimulate = 365;
|
||||||
$: deckSize = 0;
|
$: deckSize = 0;
|
||||||
$: windowSize = Math.ceil(daysToSimulate / 365);
|
$: windowSize = Math.ceil(daysToSimulate / 365);
|
||||||
|
$: processing = simulating || computingRetention;
|
||||||
|
|
||||||
function movingAverage(y: number[], windowSize: number): number[] {
|
function movingAverage(y: number[], windowSize: number): number[] {
|
||||||
const result: number[] = [];
|
const result: number[] = [];
|
||||||
|
@ -75,14 +83,61 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
return arr1.map((value, index) => value + arr2[index]);
|
return arr1.map((value, index) => value + arr2[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function simulateFsrs(): Promise<void> {
|
function estimatedRetention(retention: number): String {
|
||||||
let resp: SimulateFsrsReviewResponse | undefined;
|
if (!retention) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return tr.deckConfigPredictedOptimalRetention({ num: retention.toFixed(2) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRequest() {
|
||||||
simulateFsrsRequest.daysToSimulate = daysToSimulate;
|
simulateFsrsRequest.daysToSimulate = daysToSimulate;
|
||||||
simulateFsrsRequest.deckSize = deckSize;
|
simulateFsrsRequest.deckSize = deckSize;
|
||||||
simulateFsrsRequest.suspendAfterLapseCount = suspendLeeches
|
simulateFsrsRequest.suspendAfterLapseCount = suspendLeeches
|
||||||
? leechThreshold
|
? leechThreshold
|
||||||
: undefined;
|
: undefined;
|
||||||
simulateFsrsRequest.easyDaysPercentages = easyDayPercentages;
|
simulateFsrsRequest.easyDaysPercentages = easyDayPercentages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRetentionProgress(
|
||||||
|
val: ComputeRetentionProgress | undefined,
|
||||||
|
): String {
|
||||||
|
if (!val) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return tr.deckConfigIterations({ count: val.current });
|
||||||
|
}
|
||||||
|
|
||||||
|
$: computeRetentionProgressString = renderRetentionProgress(
|
||||||
|
computeRetentionProgress,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function computeRetention() {
|
||||||
|
let resp: ComputeOptimalRetentionResponse | undefined;
|
||||||
|
updateRequest();
|
||||||
|
try {
|
||||||
|
await runWithBackendProgress(
|
||||||
|
async () => {
|
||||||
|
computingRetention = true;
|
||||||
|
resp = await computeOptimalRetention(simulateFsrsRequest);
|
||||||
|
},
|
||||||
|
(progress) => {
|
||||||
|
if (progress.value.case === "computeRetention") {
|
||||||
|
computeRetentionProgress = progress.value.value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
computingRetention = false;
|
||||||
|
if (resp) {
|
||||||
|
optimalRetention = resp.optimalRetention;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function simulateFsrs(): Promise<void> {
|
||||||
|
let resp: SimulateFsrsReviewResponse | undefined;
|
||||||
|
updateRequest();
|
||||||
try {
|
try {
|
||||||
await runWithBackendProgress(
|
await runWithBackendProgress(
|
||||||
async () => {
|
async () => {
|
||||||
|
@ -328,6 +383,39 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
{/if}
|
{/if}
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<div class="m-2">
|
||||||
|
<details>
|
||||||
|
<summary>{tr.deckConfigComputeOptimalRetention()}</summary>
|
||||||
|
<button
|
||||||
|
class="btn {computingRetention
|
||||||
|
? 'btn-warning'
|
||||||
|
: 'btn-primary'}"
|
||||||
|
disabled={!computingRetention && computing}
|
||||||
|
on:click={() => computeRetention()}
|
||||||
|
>
|
||||||
|
{#if computingRetention}
|
||||||
|
{tr.actionsCancel()}
|
||||||
|
{:else}
|
||||||
|
{tr.deckConfigComputeButton()}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if optimalRetention}
|
||||||
|
{estimatedRetention(optimalRetention)}
|
||||||
|
{#if optimalRetention - $config.desiredRetention >= 0.01}
|
||||||
|
<Warning
|
||||||
|
warning={tr.deckConfigDesiredRetentionBelowOptimal()}
|
||||||
|
className="alert-warning"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if computingRetention}
|
||||||
|
<div>{computeRetentionProgressString}</div>
|
||||||
|
{/if}
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
|
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
|
||||||
disabled={computing}
|
disabled={computing}
|
||||||
|
@ -366,7 +454,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
{tr.deckConfigSaveOptionsToPreset()}
|
{tr.deckConfigSaveOptionsToPreset()}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if simulating}
|
{#if processing}
|
||||||
{tr.actionsProcessing()}
|
{tr.actionsProcessing()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue