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,