diff --git a/ftl/core/actions.ftl b/ftl/core/actions.ftl index 4f47f9324..8dc3b452d 100644 --- a/ftl/core/actions.ftl +++ b/ftl/core/actions.ftl @@ -50,6 +50,7 @@ actions-select = Select actions-shortcut-key = Shortcut key: { $val } actions-suspend-card = Suspend Card actions-set-due-date = Set Due Date +actions-grade-now = Grade Now actions-answer-card = Answer Card actions-unbury-unsuspend = Unbury/Unsuspend actions-add-deck = Add Deck diff --git a/ftl/core/scheduling.ftl b/ftl/core/scheduling.ftl index dda07f407..47abd762c 100644 --- a/ftl/core/scheduling.ftl +++ b/ftl/core/scheduling.ftl @@ -172,6 +172,11 @@ scheduling-set-due-date-done = [one] Set due date of { $cards } card. *[other] Set due date of { $cards } cards. } +scheduling-graded-cards-done = + { $cards -> + [one] Graded { $cards } card. + *[other] Graded { $cards } cards. + } scheduling-forgot-cards = { $cards -> [one] Reset { $cards } card. diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto index 42befe816..124f63d56 100644 --- a/proto/anki/scheduler.proto +++ b/proto/anki/scheduler.proto @@ -36,6 +36,7 @@ service SchedulerService { rpc ScheduleCardsAsNewDefaults(ScheduleCardsAsNewDefaultsRequest) returns (ScheduleCardsAsNewDefaultsResponse); rpc SetDueDate(SetDueDateRequest) returns (collection.OpChanges); + rpc GradeNow(GradeNowRequest) returns (collection.OpChanges); rpc SortCards(SortCardsRequest) returns (collection.OpChangesWithCount); rpc SortDeck(SortDeckRequest) returns (collection.OpChangesWithCount); rpc GetSchedulingStates(cards.CardId) returns (SchedulingStates); @@ -238,6 +239,11 @@ message SetDueDateRequest { config.OptionalStringConfigKey config_key = 3; } +message GradeNowRequest { + repeated int64 card_ids = 1; + CardAnswer.Rating rating = 2; +} + message SortCardsRequest { repeated int64 card_ids = 1; uint32 starting_from = 2; diff --git a/qt/aqt/browser/browser.py b/qt/aqt/browser/browser.py index 524887b08..77a48dbf5 100644 --- a/qt/aqt/browser/browser.py +++ b/qt/aqt/browser/browser.py @@ -3,6 +3,7 @@ from __future__ import annotations +import functools import json import math import re @@ -37,6 +38,7 @@ from aqt.operations.note import remove_notes from aqt.operations.scheduling import ( bury_cards, forget_cards, + grade_now, reposition_new_cards_dialog, set_due_date_dialog, suspend_cards, @@ -340,6 +342,7 @@ class Browser(QMainWindow): qconnect(f.action_Info.triggered, self.showCardInfo) qconnect(f.actionReposition.triggered, self.reposition) qconnect(f.action_set_due_date.triggered, self.set_due_date) + qconnect(f.action_grade_now.triggered, self.grade_now) qconnect(f.action_forget.triggered, self.forget_cards) qconnect(f.actionToggle_Suspend.triggered, self.suspend_selected_cards) qconnect(f.action_toggle_bury.triggered, self.bury_selected_cards) @@ -1080,6 +1083,43 @@ class Browser(QMainWindow): ): op.run_in_background() + @no_arg_trigger + @skip_if_selection_is_empty + @ensure_editor_saved + def grade_now(self) -> None: + """Show dialog to grade selected cards.""" + dialog = QDialog(self) + dialog.setWindowTitle(tr.actions_grade_now()) + layout = QHBoxLayout() + dialog.setLayout(layout) + + # Add grade buttons + for ease, label in [ + (1, tr.studying_again()), + (2, tr.studying_hard()), + (3, tr.studying_good()), + (4, tr.studying_easy()), + ]: + btn = QPushButton(label) + qconnect( + btn.clicked, + functools.partial( + grade_now, + parent=self, + card_ids=self.selected_cards(), + ease=ease, + dialog=dialog, + ), + ) + layout.addWidget(btn) + + # Add cancel button + cancel_btn = QPushButton(tr.actions_cancel()) + qconnect(cancel_btn.clicked, dialog.reject) + layout.addWidget(cancel_btn) + + dialog.exec() + # Edit: selection ###################################################################### diff --git a/qt/aqt/forms/browser.ui b/qt/aqt/forms/browser.ui index eb2fcaaf5..8820ea837 100644 --- a/qt/aqt/forms/browser.ui +++ b/qt/aqt/forms/browser.ui @@ -267,6 +267,7 @@ + @@ -623,6 +624,14 @@ Ctrl+Shift+D + + + actions_grade_now + + + Ctrl+Shift+G + + qt_accel_forget diff --git a/qt/aqt/operations/scheduling.py b/qt/aqt/operations/scheduling.py index 61d7cc9d4..958448388 100644 --- a/qt/aqt/operations/scheduling.py +++ b/qt/aqt/operations/scheduling.py @@ -65,6 +65,35 @@ def set_due_date_dialog( ) +def grade_now( + *, + parent: QWidget, + card_ids: Sequence[CardId], + ease: int, + dialog: QDialog, +) -> None: + if ease == 1: + rating = CardAnswer.AGAIN + elif ease == 2: + rating = CardAnswer.HARD + elif ease == 3: + rating = CardAnswer.GOOD + else: + rating = CardAnswer.EASY + CollectionOp( + parent, + lambda col: col._backend.grade_now( + card_ids=card_ids, + rating=rating, + ), + ).success( + lambda _: tooltip( + tr.scheduling_graded_cards_done(cards=len(card_ids)), parent=parent + ) + ).run_in_background() + dialog.accept() + + def forget_cards( *, parent: QWidget, diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index 54780300d..e2e31c9e5 100644 --- a/rslib/src/ops.rs +++ b/rslib/src/ops.rs @@ -32,6 +32,7 @@ pub enum Op { ScheduleAsNew, SetCardDeck, SetDueDate, + GradeNow, SetFlag, SortCards, Suspend, @@ -65,6 +66,7 @@ impl Op { Op::RenameDeck => tr.actions_rename_deck(), Op::ScheduleAsNew => tr.actions_forget_card(), Op::SetDueDate => tr.actions_set_due_date(), + Op::GradeNow => tr.actions_grade_now(), Op::Suspend => tr.studying_suspend(), Op::UnburyUnsuspend => tr.actions_unbury_unsuspend(), Op::UpdateCard => tr.actions_update_card(), diff --git a/rslib/src/scheduler/answering/mod.rs b/rslib/src/scheduler/answering/mod.rs index fa7228561..1bd5237c9 100644 --- a/rslib/src/scheduler/answering/mod.rs +++ b/rslib/src/scheduler/answering/mod.rs @@ -52,6 +52,7 @@ pub struct CardAnswer { pub answered_at: TimestampMillis, pub milliseconds_taken: u32, pub custom_data: Option, + pub from_queue: bool, } impl CardAnswer { @@ -312,7 +313,7 @@ impl Collection { self.transact(Op::AnswerCard, |col| col.answer_card_inner(answer)) } - fn answer_card_inner(&mut self, answer: &mut CardAnswer) -> Result<()> { + pub(crate) fn answer_card_inner(&mut self, answer: &mut CardAnswer) -> Result<()> { let card = self .storage .get_card(answer.card_id)? @@ -363,14 +364,24 @@ impl Collection { } } - self.update_queues_after_answering_card( - &card, - timing, - matches!( - answer.new_state, - CardState::Filtered(FilteredState::Preview(PreviewState { finished: true, .. })) - ), - ) + // Handle queue updates based on from_queue flag + if answer.from_queue { + self.update_queues_after_answering_card( + &card, + timing, + matches!( + answer.new_state, + CardState::Filtered(FilteredState::Preview(PreviewState { + finished: true, + .. + })) + ), + )?; + } else if card.queue == CardQueue::Suspended { + invalid_input!("Can't answer suspended cards"); + } + + Ok(()) } fn maybe_bury_siblings(&mut self, card: &Card, config: &DeckConfig) -> Result<()> { @@ -588,6 +599,7 @@ pub mod test_helpers { answered_at: TimestampMillis::now(), milliseconds_taken: 0, custom_data: None, + from_queue: true, })?; Ok(PostAnswerState { card_id: queued.card.id, diff --git a/rslib/src/scheduler/answering/preview.rs b/rslib/src/scheduler/answering/preview.rs index 00da45b19..586223af9 100644 --- a/rslib/src/scheduler/answering/preview.rs +++ b/rslib/src/scheduler/answering/preview.rs @@ -96,6 +96,7 @@ mod test { answered_at: TimestampMillis::now(), milliseconds_taken: 0, custom_data: None, + from_queue: true, })?; c = col.storage.get_card(c.id)?.unwrap(); @@ -111,6 +112,7 @@ mod test { answered_at: TimestampMillis::now(), milliseconds_taken: 0, custom_data: None, + from_queue: true, })?; c = col.storage.get_card(c.id)?.unwrap(); assert_eq!(c.queue, CardQueue::PreviewRepeat); @@ -126,6 +128,7 @@ mod test { answered_at: TimestampMillis::now(), milliseconds_taken: 0, custom_data: None, + from_queue: true, })?; c = col.storage.get_card(c.id)?.unwrap(); assert_eq!(c.queue, CardQueue::DayLearn); diff --git a/rslib/src/scheduler/reviews.rs b/rslib/src/scheduler/reviews.rs index ea2f3cc43..983ecc452 100644 --- a/rslib/src/scheduler/reviews.rs +++ b/rslib/src/scheduler/reviews.rs @@ -8,6 +8,7 @@ use rand::distributions::Distribution; use rand::distributions::Uniform; use regex::Regex; +use super::answering::CardAnswer; use crate::card::Card; use crate::card::CardId; use crate::card::CardQueue; @@ -143,6 +144,34 @@ impl Collection { Ok(()) }) } + + pub fn grade_now(&mut self, cids: &[CardId], rating: i32) -> Result> { + self.transact(Op::GradeNow, |col| { + for &card_id in cids { + let states = col.get_scheduling_states(card_id)?; + let new_state = match rating { + 0 => states.again, + 1 => states.hard, + 2 => states.good, + 3 => states.easy, + _ => invalid_input!("invalid rating"), + }; + let mut answer: CardAnswer = anki_proto::scheduler::CardAnswer { + card_id: card_id.into(), + current_state: Some(states.current.into()), + new_state: Some(new_state.into()), + rating, + milliseconds_taken: 0, + answered_at_millis: TimestampMillis::now().into(), + } + .into(); + // Process the card without updating queues yet + answer.from_queue = false; + col.answer_card_inner(&mut answer)?; + } + Ok(()) + }) + } } #[cfg(test)] diff --git a/rslib/src/scheduler/service/answering.rs b/rslib/src/scheduler/service/answering.rs index 65e98562f..778cf49a1 100644 --- a/rslib/src/scheduler/service/answering.rs +++ b/rslib/src/scheduler/service/answering.rs @@ -21,6 +21,7 @@ impl From for CardAnswer { answered_at: TimestampMillis(answer.answered_at_millis), milliseconds_taken: answer.milliseconds_taken, custom_data, + from_queue: true, } } } diff --git a/rslib/src/scheduler/service/mod.rs b/rslib/src/scheduler/service/mod.rs index 9f2116c5d..e77dce6b3 100644 --- a/rslib/src/scheduler/service/mod.rs +++ b/rslib/src/scheduler/service/mod.rs @@ -165,6 +165,14 @@ impl crate::services::SchedulerService for Collection { self.set_due_date(&cids, &days, config).map(Into::into) } + fn grade_now( + &mut self, + input: scheduler::GradeNowRequest, + ) -> Result { + self.grade_now(&input.card_ids.into_newtype(CardId), input.rating) + .map(Into::into) + } + fn sort_cards( &mut self, input: scheduler::SortCardsRequest,