From b9c3b12f715f48586734b4b7c29f5819b026986c Mon Sep 17 00:00:00 2001 From: RumovZ Date: Wed, 9 Mar 2022 07:51:41 +0100 Subject: [PATCH] Optionally restore original position and reset counts when forgetting (#1714) * Add forget prompt with options - Restore original position - Reset reps and lapses * Restore position when resetting for export * Add config context to avoid passing keys * Add routine to fetch defaults; use method-specific enum (dae) * Keep original position by default (dae) * Fix code completion for forget dialog (dae) Needs to be a symbolic link to the generated file --- ftl/core/scheduling.ftl | 2 + proto/anki/config.proto | 4 ++ proto/anki/scheduler.proto | 18 ++++++ pylib/.pylintrc | 1 + pylib/anki/scheduler/base.py | 30 ++++++++-- qt/.pylintrc | 1 + qt/aqt/browser/browser.py | 7 ++- qt/aqt/forms/__init__.py | 1 + qt/aqt/forms/forget.py | 6 ++ qt/aqt/forms/forget.ui | 94 ++++++++++++++++++++++++++++++ qt/aqt/forms/forget_qt6.py | 1 + qt/aqt/operations/scheduling.py | 34 ++++++++++- qt/aqt/reviewer.py | 7 ++- rslib/src/backend/config.rs | 4 ++ rslib/src/backend/scheduler/mod.rs | 21 ++++++- rslib/src/config/bool.rs | 6 ++ rslib/src/scheduler/new.rs | 61 +++++++++++++++++-- 17 files changed, 278 insertions(+), 20 deletions(-) create mode 100644 qt/aqt/forms/forget.py create mode 100644 qt/aqt/forms/forget.ui create mode 120000 qt/aqt/forms/forget_qt6.py diff --git a/ftl/core/scheduling.ftl b/ftl/core/scheduling.ftl index 435e1fb27..6ae1994fd 100644 --- a/ftl/core/scheduling.ftl +++ b/ftl/core/scheduling.ftl @@ -135,6 +135,8 @@ scheduling-new-options-group-name = New options group name: scheduling-options-group = Options group: scheduling-order = Order scheduling-parent-limit = (parent limit: { $val }) +scheduling-reset-counts = Reset repetition and lapse counts +scheduling-restore-position = Restore original position where possible scheduling-review = Review scheduling-reviews = Reviews scheduling-seconds = seconds diff --git a/proto/anki/config.proto b/proto/anki/config.proto index f1ad1119d..8c793d6d9 100644 --- a/proto/anki/config.proto +++ b/proto/anki/config.proto @@ -41,6 +41,10 @@ message ConfigKey { PASTE_STRIPS_FORMATTING = 16; NORMALIZE_NOTE_TEXT = 17; IGNORE_ACCENTS_IN_SEARCH = 18; + RESTORE_POSITION_BROWSER = 19; + RESTORE_POSITION_REVIEWER = 20; + RESET_COUNTS_BROWSER = 21; + RESET_COUNTS_REVIEWER = 22; } enum String { SET_DUE_BROWSER = 0; diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto index 58d016c86..ce6860d9f 100644 --- a/proto/anki/scheduler.proto +++ b/proto/anki/scheduler.proto @@ -30,6 +30,8 @@ service SchedulerService { rpc RebuildFilteredDeck(decks.DeckId) returns (collection.OpChangesWithCount); rpc ScheduleCardsAsNew(ScheduleCardsAsNewRequest) returns (collection.OpChanges); + rpc ScheduleCardsAsNewDefaults(ScheduleCardsAsNewDefaultsRequest) + returns (ScheduleCardsAsNewDefaultsResponse); rpc SetDueDate(SetDueDateRequest) returns (collection.OpChanges); rpc SortCards(SortCardsRequest) returns (collection.OpChangesWithCount); rpc SortDeck(SortDeckRequest) returns (collection.OpChangesWithCount); @@ -172,8 +174,24 @@ message BuryOrSuspendCardsRequest { } message ScheduleCardsAsNewRequest { + enum Context { + BROWSER = 0; + REVIEWER = 1; + } repeated int64 card_ids = 1; bool log = 2; + bool restore_position = 3; + bool reset_counts = 4; + optional Context context = 5; +} + +message ScheduleCardsAsNewDefaultsRequest { + ScheduleCardsAsNewRequest.Context context = 1; +} + +message ScheduleCardsAsNewDefaultsResponse { + bool restore_position = 1; + bool reset_counts = 2; } message SetDueDateRequest { diff --git a/pylib/.pylintrc b/pylib/.pylintrc index 9901aa66c..c9d947569 100644 --- a/pylib/.pylintrc +++ b/pylib/.pylintrc @@ -21,6 +21,7 @@ ignored-classes= StripHtmlRequest, CustomStudyRequest, Cram, + ScheduleCardsAsNewRequest, [REPORTS] output-format=colorized diff --git a/pylib/anki/scheduler/base.py b/pylib/anki/scheduler/base.py index 1fcdb746c..6f3c4f6c6 100644 --- a/pylib/anki/scheduler/base.py +++ b/pylib/anki/scheduler/base.py @@ -15,6 +15,8 @@ CongratsInfo = scheduler_pb2.CongratsInfoResponse UnburyDeck = scheduler_pb2.UnburyDeckRequest BuryOrSuspend = scheduler_pb2.BuryOrSuspendCardsRequest CustomStudyRequest = scheduler_pb2.CustomStudyRequest +ScheduleCardsAsNew = scheduler_pb2.ScheduleCardsAsNewRequest +ScheduleCardsAsNewDefaults = scheduler_pb2.ScheduleCardsAsNewDefaultsResponse FilteredDeckForUpdate = decks_pb2.FilteredDeckForUpdate @@ -163,9 +165,28 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l # Resetting/rescheduling ########################################################################## - def schedule_cards_as_new(self, card_ids: Sequence[CardId]) -> OpChanges: - "Put cards at the end of the new queue." - return self.col._backend.schedule_cards_as_new(card_ids=card_ids, log=True) + def schedule_cards_as_new( + self, + card_ids: Sequence[CardId], + *, + restore_position: bool = False, + reset_counts: bool = False, + context: ScheduleCardsAsNew.Context.V | None = None, + ) -> OpChanges: + "Place cards back into the new queue." + request = ScheduleCardsAsNew( + card_ids=card_ids, + log=True, + restore_position=restore_position, + reset_counts=reset_counts, + context=context, + ) + return self.col._backend.schedule_cards_as_new(request) + + def schedule_cards_as_new_defaults( + self, context: ScheduleCardsAsNew.Context.V + ) -> ScheduleCardsAsNewDefaults: + return self.col._backend.schedule_cards_as_new_defaults(context) def set_due_date( self, @@ -203,7 +224,8 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l " where id in %s" % sids ) # and forget any non-new cards, changing their due numbers - self.col._backend.schedule_cards_as_new(card_ids=non_new, log=False) + request = ScheduleCardsAsNew(card_ids=non_new, log=False, restore_position=True) + self.col._backend.schedule_cards_as_new(request) # Repositioning new cards ########################################################################## diff --git a/qt/.pylintrc b/qt/.pylintrc index 4068ea43e..e03c29813 100644 --- a/qt/.pylintrc +++ b/qt/.pylintrc @@ -17,6 +17,7 @@ ignored-classes= ChangeNotetypeRequest, CustomStudyRequest, Cram, + ScheduleCardsAsNewRequest, [REPORTS] output-format=colorized diff --git a/qt/aqt/browser/browser.py b/qt/aqt/browser/browser.py index 978991b4c..4a314feac 100644 --- a/qt/aqt/browser/browser.py +++ b/qt/aqt/browser/browser.py @@ -18,6 +18,7 @@ from anki.consts import * from anki.errors import NotFoundError from anki.lang import without_unicode_isolation from anki.notes import NoteId +from anki.scheduler.base import ScheduleCardsAsNew from anki.tags import MARKED_TAG from anki.utils import is_mac from aqt import AnkiQt, gui_hooks @@ -862,10 +863,12 @@ class Browser(QMainWindow): @skip_if_selection_is_empty @ensure_editor_saved def forget_cards(self) -> None: - forget_cards( + if op := forget_cards( parent=self, card_ids=self.selected_cards(), - ).run_in_background() + context=ScheduleCardsAsNew.Context.BROWSER, + ): + op.run_in_background() # Edit: selection ###################################################################### diff --git a/qt/aqt/forms/__init__.py b/qt/aqt/forms/__init__.py index dd1433a83..d304d6956 100644 --- a/qt/aqt/forms/__init__.py +++ b/qt/aqt/forms/__init__.py @@ -14,6 +14,7 @@ from . import customstudy from . import dconf from . import debug from . import filtered_deck +from . import forget from . import editaddon from . import editcurrent from . import edithtml diff --git a/qt/aqt/forms/forget.py b/qt/aqt/forms/forget.py new file mode 100644 index 000000000..8c7231bf3 --- /dev/null +++ b/qt/aqt/forms/forget.py @@ -0,0 +1,6 @@ +from aqt.qt import qtmajor + +if qtmajor > 5: + from .forget_qt6 import * +else: + from .forget_qt5 import * # type: ignore diff --git a/qt/aqt/forms/forget.ui b/qt/aqt/forms/forget.ui new file mode 100644 index 000000000..03e44fdda --- /dev/null +++ b/qt/aqt/forms/forget.ui @@ -0,0 +1,94 @@ + + + Dialog + + + + 0 + 0 + 235 + 118 + + + + actions_forget_card + + + true + + + + + + scheduling_restore_position + + + + + + + scheduling_reset_counts + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/qt/aqt/forms/forget_qt6.py b/qt/aqt/forms/forget_qt6.py new file mode 120000 index 000000000..008c39912 --- /dev/null +++ b/qt/aqt/forms/forget_qt6.py @@ -0,0 +1 @@ +../../../.bazel/bin/qt/aqt/forms/forget_qt6.py \ No newline at end of file diff --git a/qt/aqt/operations/scheduling.py b/qt/aqt/operations/scheduling.py index 624496f1a..b921dd680 100644 --- a/qt/aqt/operations/scheduling.py +++ b/qt/aqt/operations/scheduling.py @@ -19,6 +19,7 @@ from anki.collection import ( from anki.decks import DeckId from anki.notes import NoteId from anki.scheduler import CustomStudyRequest, FilteredDeckForUpdate, UnburyDeck +from anki.scheduler.base import ScheduleCardsAsNew from anki.scheduler.v3 import CardAnswer from anki.scheduler.v3 import Scheduler as V3Scheduler from aqt.operations import CollectionOp @@ -65,10 +66,37 @@ def set_due_date_dialog( def forget_cards( - *, parent: QWidget, card_ids: Sequence[CardId] -) -> CollectionOp[OpChanges]: + *, + parent: QWidget, + card_ids: Sequence[CardId], + context: ScheduleCardsAsNew.Context.V | None = None, +) -> CollectionOp[OpChanges] | None: + assert aqt.mw + + dialog = QDialog(parent) + disable_help_button(dialog) + form = aqt.forms.forget.Ui_Dialog() + form.setupUi(dialog) + + if context is not None: + defaults = aqt.mw.col.sched.schedule_cards_as_new_defaults(context) + form.restore_position.setChecked(defaults.restore_position) + form.reset_counts.setChecked(defaults.reset_counts) + + if not dialog.exec(): + return None + + restore_position = form.restore_position.isChecked() + reset_counts = form.reset_counts.isChecked() + return CollectionOp( - parent, lambda col: col.sched.schedule_cards_as_new(card_ids) + parent, + lambda col: col.sched.schedule_cards_as_new( + card_ids, + restore_position=restore_position, + reset_counts=reset_counts, + context=context, + ), ).success( lambda _: tooltip( tr.scheduling_forgot_cards(cards=len(card_ids)), parent=parent diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index b0b0e3b67..a6a35a350 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -19,6 +19,7 @@ import aqt.operations from anki import hooks from anki.cards import Card, CardId from anki.collection import Config, OpChanges, OpChangesWithCount +from anki.scheduler.base import ScheduleCardsAsNew from anki.scheduler.v3 import CardAnswer, NextStates, QueuedCards from anki.scheduler.v3 import Scheduler as V3Scheduler from anki.tags import MARKED_TAG @@ -1067,10 +1068,12 @@ time = %(time)d; ).run_in_background() def forget_current_card(self) -> None: - forget_cards( + if op := forget_cards( parent=self.mw, card_ids=[self.card.id], - ).run_in_background() + context=ScheduleCardsAsNew.Context.REVIEWER, + ): + op.run_in_background() def on_create_copy(self) -> None: if self.card: diff --git a/rslib/src/backend/config.rs b/rslib/src/backend/config.rs index b4eb8fe4a..912db2f0d 100644 --- a/rslib/src/backend/config.rs +++ b/rslib/src/backend/config.rs @@ -32,6 +32,10 @@ impl From for BoolKey { BoolKeyProto::PasteStripsFormatting => BoolKey::PasteStripsFormatting, BoolKeyProto::NormalizeNoteText => BoolKey::NormalizeNoteText, BoolKeyProto::IgnoreAccentsInSearch => BoolKey::IgnoreAccentsInSearch, + BoolKeyProto::RestorePositionBrowser => BoolKey::RestorePositionBrowser, + BoolKeyProto::RestorePositionReviewer => BoolKey::RestorePositionReviewer, + BoolKeyProto::ResetCountsBrowser => BoolKey::ResetCountsBrowser, + BoolKeyProto::ResetCountsReviewer => BoolKey::ResetCountsReviewer, } } } diff --git a/rslib/src/backend/scheduler/mod.rs b/rslib/src/backend/scheduler/mod.rs index 77625ab1b..341431f3b 100644 --- a/rslib/src/backend/scheduler/mod.rs +++ b/rslib/src/backend/scheduler/mod.rs @@ -7,7 +7,7 @@ mod states; use super::Backend; pub(super) use crate::backend_proto::scheduler_service::Service as SchedulerService; use crate::{ - backend_proto::{self as pb}, + backend_proto as pb, prelude::*, scheduler::{ new::NewCardDueOrder, @@ -111,11 +111,26 @@ impl SchedulerService for Backend { fn schedule_cards_as_new(&self, input: pb::ScheduleCardsAsNewRequest) -> Result { self.with_col(|col| { let cids = input.card_ids.into_newtype(CardId); - let log = input.log; - col.reschedule_cards_as_new(&cids, log).map(Into::into) + col.reschedule_cards_as_new( + &cids, + input.log, + input.restore_position, + input.reset_counts, + input + .context + .and_then(pb::schedule_cards_as_new_request::Context::from_i32), + ) + .map(Into::into) }) } + fn schedule_cards_as_new_defaults( + &self, + input: pb::ScheduleCardsAsNewDefaultsRequest, + ) -> Result { + self.with_col(|col| Ok(col.reschedule_cards_as_new_defaults(input.context()))) + } + fn set_due_date(&self, input: pb::SetDueDateRequest) -> Result { let config = input.config_key.map(|v| v.key().into()); let days = input.days; diff --git a/rslib/src/config/bool.rs b/rslib/src/config/bool.rs index 0d5aafc9a..3820e4095 100644 --- a/rslib/src/config/bool.rs +++ b/rslib/src/config/bool.rs @@ -27,6 +27,10 @@ pub enum BoolKey { PreviewBothSides, Sched2021, IgnoreAccentsInSearch, + RestorePositionBrowser, + RestorePositionReviewer, + ResetCountsBrowser, + ResetCountsReviewer, #[strum(to_string = "normalize_note_text")] NormalizeNoteText, @@ -55,6 +59,8 @@ impl Collection { | BoolKey::FutureDueShowBacklog | BoolKey::ShowRemainingDueCountsInStudy | BoolKey::CardCountsSeparateInactive + | BoolKey::RestorePositionBrowser + | BoolKey::RestorePositionReviewer | BoolKey::NormalizeNoteText => self.get_config_optional(key).unwrap_or(true), // other options default to false diff --git a/rslib/src/scheduler/new.rs b/rslib/src/scheduler/new.rs index 5c39344cc..b40d06e8a 100644 --- a/rslib/src/scheduler/new.rs +++ b/rslib/src/scheduler/new.rs @@ -5,16 +5,20 @@ use std::collections::{HashMap, HashSet}; use rand::seq::SliceRandom; +pub use crate::backend_proto::scheduler::{ + schedule_cards_as_new_request::Context as ScheduleAsNewContext, + ScheduleCardsAsNewDefaultsResponse, +}; use crate::{ card::{CardQueue, CardType}, - config::SchedulerVersion, + config::{BoolKey, SchedulerVersion}, deckconfig::NewCardInsertOrder, prelude::*, search::{SearchNode, SortMode, StateKind}, }; impl Card { - fn schedule_as_new(&mut self, position: u32) { + fn schedule_as_new(&mut self, position: u32, reset_counts: bool) { self.remove_from_filtered_deck_before_reschedule(); self.due = position as i32; self.ctype = CardType::New; @@ -22,6 +26,10 @@ impl Card { self.interval = 0; self.ease_factor = 0; self.original_position = None; + if reset_counts { + self.reps = 0; + self.lapses = 0; + } } /// If the card is new, change its position, and return true. @@ -112,7 +120,14 @@ fn nids_in_preserved_order(cards: &[Card]) -> Vec { } impl Collection { - pub fn reschedule_cards_as_new(&mut self, cids: &[CardId], log: bool) -> Result> { + pub fn reschedule_cards_as_new( + &mut self, + cids: &[CardId], + log: bool, + restore_position: bool, + reset_counts: bool, + context: Option, + ) -> Result> { let usn = self.usn()?; let mut position = self.get_next_card_position(); self.transact(Op::ScheduleAsNew, |col| { @@ -120,18 +135,52 @@ impl Collection { let cards = col.storage.all_searched_cards_in_search_order()?; for mut card in cards { let original = card.clone(); - card.schedule_as_new(position); + if restore_position && card.original_position.is_some() { + card.schedule_as_new(card.original_position.unwrap(), reset_counts); + } else { + card.schedule_as_new(position, reset_counts); + position += 1; + } if log { col.log_manually_scheduled_review(&card, &original, usn)?; } col.update_card_inner(&mut card, original, usn)?; - position += 1; } col.set_next_card_position(position)?; - col.storage.clear_searched_cards_table() + col.storage.clear_searched_cards_table()?; + + match context { + Some(ScheduleAsNewContext::Browser) => { + col.set_config_bool_inner(BoolKey::RestorePositionBrowser, restore_position)?; + col.set_config_bool_inner(BoolKey::ResetCountsBrowser, reset_counts)?; + } + Some(ScheduleAsNewContext::Reviewer) => { + col.set_config_bool_inner(BoolKey::RestorePositionReviewer, restore_position)?; + col.set_config_bool_inner(BoolKey::ResetCountsReviewer, reset_counts)?; + } + None => (), + } + + Ok(()) }) } + pub fn reschedule_cards_as_new_defaults( + &self, + context: ScheduleAsNewContext, + ) -> ScheduleCardsAsNewDefaultsResponse { + match context { + ScheduleAsNewContext::Browser => ScheduleCardsAsNewDefaultsResponse { + restore_position: self.get_config_bool(BoolKey::RestorePositionBrowser), + reset_counts: self.get_config_bool(BoolKey::ResetCountsBrowser), + }, + ScheduleAsNewContext::Reviewer => ScheduleCardsAsNewDefaultsResponse { + restore_position: self.get_config_bool(BoolKey::RestorePositionReviewer), + reset_counts: self.get_config_bool(BoolKey::ResetCountsReviewer), + }, + } + } + pub fn sort_cards( &mut self, cids: &[CardId],