Repository: ankitects/anki
Pull request #3840: Feat/grade now
Author: login: L-M-Sherlock
Created: 2025-03-04T07:59:33Z | Merged: 2025-03-15T10:30:40Z

This PR adds a new "Grade Now" feature to the browser, allowing users to grade cards directly without going through the review process. This is useful for quickly adjusting card states in bulk.

## Changes

1. Added a new action in the browser UI with keyboard shortcut Ctrl+Shift+G
2. Added backend support for grading cards outside the normal review flow
3. Added proper queue management for graded cards
4. Added translation strings and UI elements

## Implementation Details

The implementation includes:

- A new `GradeNow` operation in the backend
- A dialog in the browser that shows the four standard grading buttons (Again, Hard, Good, Easy)
- Proper handling of queue updates after grading
- Support for grading multiple selected cards at once

## Screenshots

<img width="497" alt="image" src="https://github.com/user-attachments/assets/f199737c-1cfb-4580-a41d-c7bf1ba69f0d" />

<img width="373" alt="image" src="https://github.com/user-attachments/assets/1c4365a2-7a5e-4832-9594-88b578b6b173" />

<img width="131" alt="image" src="https://github.com/user-attachments/assets/6b8c21bd-54f0-46a4-86ec-826d277af999" />

close #3694

Linked issue #3694: Provide a 'grade now' feature
Useful to e.g. push cards forward after they've been reviewed externally, and because FSRS would prefer every rescheduling have a grade associated with it.

The scheduling code currently assumes that a card will only be answered when it's at the top of the scheduling queue. The queue is potentially tens of thousands of cards big, and it's not ideal if we have to search through the entire list on every card answer to locate the matched card and remove it from the queue (in `queue/mod.rs`).

Perhaps one way we could avoid unwanted extra processing is to refactor the card answering so that there is a from_queue flag. If false, the entire queue would be searched, and there would be no error generated if the card was not found in the queue (when e.g. the user has rated a card that's not due yet).

Presumably we should ignore suspended cards in such a mode, or throw an error when attempting to reschedule them?
Linked issue comments: user: login: Expertium
created_at: 2025-02-11T14:10:11Z
body: @L-M-Sherlock IIRC replacing "Set Due Date" with "Grade Now" could help with some FSRS issues, since the problem with "Set Due Date" is that we don't know how the user would grade the card.
author_association: CONTRIBUTOR

Diff:
diff --git a/ftl/core/actions.ftl b/ftl/core/actions.ftl
index 4f47f932467..8dc3b452d8d 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 dda07f4071d..47abd762c63 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 b657ff3fa24..d623da984e4 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 524887b08c1..77a48dbf531 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.scheduling import (
     bury_cards,
     forget_cards,
+    grade_now,
     reposition_new_cards_dialog,
     set_due_date_dialog,
     suspend_cards,
@@ -340,6 +342,7 @@ def setupMenus(self) -> None:
         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 @@ def forget_cards(self) -> None:
         ):
             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 eb2fcaaf5dd..8820ea8370a 100644
--- a/qt/aqt/forms/browser.ui
+++ b/qt/aqt/forms/browser.ui
@@ -267,6 +267,7 @@
     <addaction name="actionChange_Deck"/>
     <addaction name="separator"/>
     <addaction name="action_set_due_date"/>
+    <addaction name="action_grade_now"/>
     <addaction name="action_forget"/>
     <addaction name="actionReposition"/>
     <addaction name="separator"/>
@@ -623,6 +624,14 @@
     <string notr="true">Ctrl+Shift+D</string>
    </property>
   </action>
+  <action name="action_grade_now">
+   <property name="text">
+    <string>actions_grade_now</string>
+   </property>
+   <property name="shortcut">
+    <string notr="true">Ctrl+Shift+G</string>
+   </property>
+  </action>
   <action name="action_forget">
    <property name="text">
     <string>qt_accel_forget</string>
diff --git a/qt/aqt/operations/scheduling.py b/qt/aqt/operations/scheduling.py
index 61d7cc9d45f..958448388b2 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 54780300d81..e2e31c9e538 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 fa722856166..1bd5237c99e 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<String>,
+    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 00da45b1969..586223af95c 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 ea2f3cc4390..983ecc45288 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<OpOutput<()>> {
+        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 65e98562f34..778cf49a1b6 100644
--- a/rslib/src/scheduler/service/answering.rs
+++ b/rslib/src/scheduler/service/answering.rs
@@ -21,6 +21,7 @@ impl From<anki_proto::scheduler::CardAnswer> 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 9f2116c5d42..e77dce6b346 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<anki_proto::collection::OpChanges> {
+        self.grade_now(&input.card_ids.into_newtype(CardId), input.rating)
+            .map(Into::into)
+    }
+
     fn sort_cards(
         &mut self,
         input: scheduler::SortCardsRequest,


Reviews:
user: login: dae
body: Thanks Jarrett, I've left some comments about the technical side of things below.

I too find myself wondering how exactly we should integrate SDD and GC into Anki. Would some sort of unified interface where the user could specify grade and/or due date make sense? Are there situations where it would make sense for FSRS users to use SDD without grading the card?
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T08:14:18Z
user: login: L-M-Sherlock
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T10:47:58Z
user: login: L-M-Sherlock
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T10:48:49Z
user: login: L-M-Sherlock
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T10:49:22Z
user: login: L-M-Sherlock
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T02:53:50Z
user: login: dae
body: Getting there! A couple more technical concerns below.

On the GUI end, I agree with @iamllama that a dropdown may be a better choice - the current pop-up feels a bit clunky, and can't be navigated easy with a keyboard. But that can be deferred to a follow-up PR by someone with more of a GUI focus.

Or not. I still also find myself wondering whether it might make sense to present these two features with a unified interface, allowing one to grade & optionally set due date or vice versa, but I don't have any great ideas for how it could be done at the moment. 


state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T09:14:14Z
user: login: L-M-Sherlock
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T10:18:23Z
user: login: L-M-Sherlock
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T10:26:23Z

Review comments:
diff_hunk: @@ -309,17 +309,25 @@ impl Collection {
     /// Answer card, writing its new state to the database.
     /// Provided [CardAnswer] has its answer time capped to deck preset.
     pub fn answer_card(&mut self, answer: &mut CardAnswer) -> Result<OpOutput<()>> {
-        self.transact(Op::AnswerCard, |col| col.answer_card_inner(answer))
+        self.transact(Op::AnswerCard, |col| col.answer_card_inner(answer, true))
path: rslib/src/scheduler/answering/mod.rs
user: login: dae
body: We're already using a struct to pass in arguments for this routine, so it would probably be more logical to extend that struct instead of introducing a new argument.
created_at: 2025-03-10T07:24:23Z
author_association: MEMBER
diff_hunk: @@ -309,17 +309,25 @@ impl Collection {
     /// Answer card, writing its new state to the database.
     /// Provided [CardAnswer] has its answer time capped to deck preset.
     pub fn answer_card(&mut self, answer: &mut CardAnswer) -> Result<OpOutput<()>> {
-        self.transact(Op::AnswerCard, |col| col.answer_card_inner(answer))
+        self.transact(Op::AnswerCard, |col| col.answer_card_inner(answer, true))
     }
 
-    fn answer_card_inner(&mut self, answer: &mut CardAnswer) -> Result<()> {
+    pub fn grade_card(&mut self, answer: &mut CardAnswer) -> Result<()> {
+        self.answer_card_inner(answer, false)
+    }
+
+    fn answer_card_inner(&mut self, answer: &mut CardAnswer, from_queue: bool) -> Result<()> {
         let card = self
             .storage
             .get_card(answer.card_id)?
             .or_not_found(answer.card_id)?;
         let original = card.clone();
         let usn = self.usn()?;
 
+        if card.queue == CardQueue::Suspended {
+            invalid_input!("Can't answer suspended cards");
+        }
+
path: rslib/src/scheduler/answering/mod.rs
user: login: dae
body: Since this can only happen in the non-queue path (outside of a race condition), it might make more sense to check only in that path.
created_at: 2025-03-10T07:25:47Z
author_association: MEMBER
diff_hunk: @@ -363,14 +371,22 @@ 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 from_queue {
+            self.update_queues_after_answering_card(
+                &card,
+                timing,
+                matches!(
+                    answer.new_state,
+                    CardState::Filtered(FilteredState::Preview(PreviewState {
+                        finished: true,
+                        ..
+                    }))
+                ),
+            )
+        } else {
+            self.update_queues_after_grading_card(&card, timing)
path: rslib/src/scheduler/answering/mod.rs
user: login: dae
body: This is not very efficient - removing a single card requires a search through the queues, and the user may have selected hundreds or thousands of cards at a time. Especially for larger selections, this is probably going to perform a lot better if the seen card ids are collected into a HashSet, and then the queues are filtered with .retain() after all cards have been processed.

Alternatively we could discards the queues and have them rebuilt, which is what is currently done when using SDD.
created_at: 2025-03-10T08:08:52Z
author_association: MEMBER
diff_hunk: @@ -309,17 +309,25 @@ impl Collection {
     /// Answer card, writing its new state to the database.
     /// Provided [CardAnswer] has its answer time capped to deck preset.
     pub fn answer_card(&mut self, answer: &mut CardAnswer) -> Result<OpOutput<()>> {
-        self.transact(Op::AnswerCard, |col| col.answer_card_inner(answer))
+        self.transact(Op::AnswerCard, |col| col.answer_card_inner(answer, true))
path: rslib/src/scheduler/answering/mod.rs
user: login: L-M-Sherlock
body: I moved it into `CardAnswer` in c1730d8.
created_at: 2025-03-10T10:47:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -309,17 +309,25 @@ impl Collection {
     /// Answer card, writing its new state to the database.
     /// Provided [CardAnswer] has its answer time capped to deck preset.
     pub fn answer_card(&mut self, answer: &mut CardAnswer) -> Result<OpOutput<()>> {
-        self.transact(Op::AnswerCard, |col| col.answer_card_inner(answer))
+        self.transact(Op::AnswerCard, |col| col.answer_card_inner(answer, true))
path: rslib/src/scheduler/answering/mod.rs
user: login: L-M-Sherlock
body: But it fails the ci test.
created_at: 2025-03-10T10:48:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -309,17 +309,25 @@ impl Collection {
     /// Answer card, writing its new state to the database.
     /// Provided [CardAnswer] has its answer time capped to deck preset.
     pub fn answer_card(&mut self, answer: &mut CardAnswer) -> Result<OpOutput<()>> {
-        self.transact(Op::AnswerCard, |col| col.answer_card_inner(answer))
+        self.transact(Op::AnswerCard, |col| col.answer_card_inner(answer, true))
     }
 
-    fn answer_card_inner(&mut self, answer: &mut CardAnswer) -> Result<()> {
+    pub fn grade_card(&mut self, answer: &mut CardAnswer) -> Result<()> {
+        self.answer_card_inner(answer, false)
+    }
+
+    fn answer_card_inner(&mut self, answer: &mut CardAnswer, from_queue: bool) -> Result<()> {
         let card = self
             .storage
             .get_card(answer.card_id)?
             .or_not_found(answer.card_id)?;
         let original = card.clone();
         let usn = self.usn()?;
 
+        if card.queue == CardQueue::Suspended {
+            invalid_input!("Can't answer suspended cards");
+        }
+
path: rslib/src/scheduler/answering/mod.rs
user: login: L-M-Sherlock
body: Done in c1730d8
created_at: 2025-03-10T10:49:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -363,14 +371,22 @@ 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 from_queue {
+            self.update_queues_after_answering_card(
+                &card,
+                timing,
+                matches!(
+                    answer.new_state,
+                    CardState::Filtered(FilteredState::Preview(PreviewState {
+                        finished: true,
+                        ..
+                    }))
+                ),
+            )
+        } else {
+            self.update_queues_after_grading_card(&card, timing)
path: rslib/src/scheduler/answering/mod.rs
user: login: L-M-Sherlock
body: I implemented the first suggestion in 91e1d6f.
created_at: 2025-03-12T02:53:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -242,6 +243,78 @@ impl Collection {
         Ok(())
     }
 
+    /// Update queues for a batch of processed cards.
+    /// This is more efficient than updating the queues one by one.
+    pub(crate) fn update_queues_for_processed_cards(
+        &mut self,
+        processed_card_ids: &HashSet<CardId>,
+    ) -> Result<()> {
+        let timing = self.timing_today()?;
+        if let Some(queues) = &mut self.state.card_queues {
+            // Remove all processed cards from the queues at once
+            if !processed_card_ids.is_empty() {
+                let mut removed_intraday_learning = 0;
+
+                // Remove from intraday learning queue
+                queues.intraday_learning.retain(|entry| {
+                    if processed_card_ids.contains(&entry.id) {
+                        removed_intraday_learning += 1;
+                        false
+                    } else {
+                        true
+                    }
+                });
+                queues.counts.learning = queues
+                    .counts
+                    .learning
+                    .saturating_sub(removed_intraday_learning);
path: rslib/src/scheduler/queue/mod.rs
user: login: dae
body: I'm not sure this current intraday handling is correct. The intraday count is calculated in `queue::builder::build()` like this:

```rust
        let learn_count = intraday_learning
            .iter()
            .take_while(|e| e.due <= cutoff)
            .count()
            + self.day_learning.len();
```

If we remove an item that's outside of the learn ahead limit, then it won't be shown in the counts, and the count shouldn't be decremented.
created_at: 2025-03-14T08:59:36Z
author_association: MEMBER
diff_hunk: @@ -242,6 +243,78 @@ impl Collection {
         Ok(())
     }
 
+    /// Update queues for a batch of processed cards.
+    /// This is more efficient than updating the queues one by one.
+    pub(crate) fn update_queues_for_processed_cards(
+        &mut self,
+        processed_card_ids: &HashSet<CardId>,
+    ) -> Result<()> {
+        let timing = self.timing_today()?;
+        if let Some(queues) = &mut self.state.card_queues {
+            // Remove all processed cards from the queues at once
+            if !processed_card_ids.is_empty() {
+                let mut removed_intraday_learning = 0;
+
+                // Remove from intraday learning queue
+                queues.intraday_learning.retain(|entry| {
+                    if processed_card_ids.contains(&entry.id) {
+                        removed_intraday_learning += 1;
+                        false
+                    } else {
+                        true
+                    }
+                });
+                queues.counts.learning = queues
+                    .counts
+                    .learning
+                    .saturating_sub(removed_intraday_learning);
+
+                let mut removed_new = 0;
+                let mut removed_review = 0;
+                let mut removed_interday_learning = 0;
+
+                // Remove from main queue
+                queues.main.retain(|entry| {
+                    if processed_card_ids.contains(&entry.id) {
+                        match entry.kind {
+                            MainQueueEntryKind::New => removed_new += 1,
+                            MainQueueEntryKind::Review => removed_review += 1,
+                            MainQueueEntryKind::InterdayLearning => removed_interday_learning += 1,
+                        }
+                        false
+                    } else {
+                        true
+                    }
+                });
+
+                // Update counts
+                queues.counts.new = queues.counts.new.saturating_sub(removed_new);
+                queues.counts.review = queues.counts.review.saturating_sub(removed_review);
+                queues.counts.learning = queues
+                    .counts
+                    .learning
+                    .saturating_sub(removed_interday_learning);
+
+                // Update learning cutoff
+                queues.update_learning_cutoff_and_count();
+
+                // Requeue any learning cards that need to be requeued
+                for &card_id in processed_card_ids {
+                    if let Some(card) = self.storage.get_card(card_id)? {
+                        if let Some(_requeued_learning) =
+                            queues.maybe_requeue_learning_card(&card, timing)
+                        {
+                            // We don't need to save undo information here since
+                            // this is a batch operation
+                            // and grade now can be undone as a normal op
+                        }
path: rslib/src/scheduler/queue/mod.rs
user: login: dae
body: Looking at this more carefully, I find myself wondering if you've actually accidentally taken the second approach I mentioned? `maybe_clear_study_queues_after_op()` does not cover the grade now case, so the next queue operation after this one causes the queues to be rebuilt. If I'm right about that, all this code could be dropped.
created_at: 2025-03-14T09:09:34Z
author_association: MEMBER
diff_hunk: @@ -242,6 +243,78 @@ impl Collection {
         Ok(())
     }
 
+    /// Update queues for a batch of processed cards.
+    /// This is more efficient than updating the queues one by one.
+    pub(crate) fn update_queues_for_processed_cards(
+        &mut self,
+        processed_card_ids: &HashSet<CardId>,
+    ) -> Result<()> {
+        let timing = self.timing_today()?;
+        if let Some(queues) = &mut self.state.card_queues {
+            // Remove all processed cards from the queues at once
+            if !processed_card_ids.is_empty() {
+                let mut removed_intraday_learning = 0;
+
+                // Remove from intraday learning queue
+                queues.intraday_learning.retain(|entry| {
+                    if processed_card_ids.contains(&entry.id) {
+                        removed_intraday_learning += 1;
+                        false
+                    } else {
+                        true
+                    }
+                });
+                queues.counts.learning = queues
+                    .counts
+                    .learning
+                    .saturating_sub(removed_intraday_learning);
+
+                let mut removed_new = 0;
+                let mut removed_review = 0;
+                let mut removed_interday_learning = 0;
+
+                // Remove from main queue
+                queues.main.retain(|entry| {
+                    if processed_card_ids.contains(&entry.id) {
+                        match entry.kind {
+                            MainQueueEntryKind::New => removed_new += 1,
+                            MainQueueEntryKind::Review => removed_review += 1,
+                            MainQueueEntryKind::InterdayLearning => removed_interday_learning += 1,
+                        }
+                        false
+                    } else {
+                        true
+                    }
+                });
+
+                // Update counts
+                queues.counts.new = queues.counts.new.saturating_sub(removed_new);
+                queues.counts.review = queues.counts.review.saturating_sub(removed_review);
+                queues.counts.learning = queues
+                    .counts
+                    .learning
+                    .saturating_sub(removed_interday_learning);
+
+                // Update learning cutoff
+                queues.update_learning_cutoff_and_count();
+
+                // Requeue any learning cards that need to be requeued
+                for &card_id in processed_card_ids {
+                    if let Some(card) = self.storage.get_card(card_id)? {
+                        if let Some(_requeued_learning) =
+                            queues.maybe_requeue_learning_card(&card, timing)
+                        {
+                            // We don't need to save undo information here since
+                            // this is a batch operation
+                            // and grade now can be undone as a normal op
+                        }
path: rslib/src/scheduler/queue/mod.rs
user: login: L-M-Sherlock
body: I didn't know `maybe_clear_study_queues_after_op()` before you mentioned it :-). I will check whether the code is useless.
created_at: 2025-03-14T10:18:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -242,6 +243,78 @@ impl Collection {
         Ok(())
     }
 
+    /// Update queues for a batch of processed cards.
+    /// This is more efficient than updating the queues one by one.
+    pub(crate) fn update_queues_for_processed_cards(
+        &mut self,
+        processed_card_ids: &HashSet<CardId>,
+    ) -> Result<()> {
+        let timing = self.timing_today()?;
+        if let Some(queues) = &mut self.state.card_queues {
+            // Remove all processed cards from the queues at once
+            if !processed_card_ids.is_empty() {
+                let mut removed_intraday_learning = 0;
+
+                // Remove from intraday learning queue
+                queues.intraday_learning.retain(|entry| {
+                    if processed_card_ids.contains(&entry.id) {
+                        removed_intraday_learning += 1;
+                        false
+                    } else {
+                        true
+                    }
+                });
+                queues.counts.learning = queues
+                    .counts
+                    .learning
+                    .saturating_sub(removed_intraday_learning);
+
+                let mut removed_new = 0;
+                let mut removed_review = 0;
+                let mut removed_interday_learning = 0;
+
+                // Remove from main queue
+                queues.main.retain(|entry| {
+                    if processed_card_ids.contains(&entry.id) {
+                        match entry.kind {
+                            MainQueueEntryKind::New => removed_new += 1,
+                            MainQueueEntryKind::Review => removed_review += 1,
+                            MainQueueEntryKind::InterdayLearning => removed_interday_learning += 1,
+                        }
+                        false
+                    } else {
+                        true
+                    }
+                });
+
+                // Update counts
+                queues.counts.new = queues.counts.new.saturating_sub(removed_new);
+                queues.counts.review = queues.counts.review.saturating_sub(removed_review);
+                queues.counts.learning = queues
+                    .counts
+                    .learning
+                    .saturating_sub(removed_interday_learning);
+
+                // Update learning cutoff
+                queues.update_learning_cutoff_and_count();
+
+                // Requeue any learning cards that need to be requeued
+                for &card_id in processed_card_ids {
+                    if let Some(card) = self.storage.get_card(card_id)? {
+                        if let Some(_requeued_learning) =
+                            queues.maybe_requeue_learning_card(&card, timing)
+                        {
+                            // We don't need to save undo information here since
+                            // this is a batch operation
+                            // and grade now can be undone as a normal op
+                        }
path: rslib/src/scheduler/queue/mod.rs
user: login: L-M-Sherlock
body: OK, I guess this info means I can remove the function `update_queues_for_processed_cards` safely?

```
[rslib/src/scheduler/queue/mod.rs:213:9] &op = OpChanges {
    op: GradeNow,
    changes: StateChanges {
        card: true,
        note: false,
        deck: true,
        tag: false,
        notetype: false,
        config: false,
        deck_config: false,
        mtime: true,
    },
}
[rslib/src/scheduler/queue/mod.rs:214:9] &op.requires_study_queue_rebuild() = true
```
created_at: 2025-03-14T10:26:22Z
author_association: CONTRIBUTOR

PR comments:
user: login: iamllama
created_at: 2025-03-04T11:53:17Z
body: Could this be implemented as a submenu on "Grade Now" instead of opening another dialog? Similar to how ajt's card management addon currently does it. I understand that the dialog mirrors the reviewer's button bar, but feel it might be too clunky in practice

![197412891-fcef27e4-49ed-4175-a42d-a50ef2252151](https://github.com/user-attachments/assets/02e92e18-fbd4-4b83-b1a1-8b5d1c941385)
author_association: CONTRIBUTOR
user: login: Expertium
created_at: 2025-03-04T11:55:59Z
body: I think "Set Due Date" should be removed then
Relevant: https://github.com/open-spaced-repetition/fsrs4anki/issues/675

Also, shouldn't it be "Rate now", since the manual usually says "ratings" and not "grades"?
Though, personally, I prefer "grades".
author_association: CONTRIBUTOR
user: login: user1823
created_at: 2025-03-04T12:46:25Z
body: > I think "Set Due Date" should be removed then

No, both have their own use cases.
- Grade now is used when the user has recalled the material externally - successfully or unsuccessfully .
- Set due date is used when the user doesn't want to answer the card at that time (e.g. got passively exposed to the information). 

SuperMemo also has both the options: https://supermemopedia.com/wiki/Ctrl%2BJ_vs._Ctrl%2BShift%2BR
author_association: CONTRIBUTOR
user: login: Expertium
created_at: 2025-03-04T12:58:26Z
body: I thought the whole point of adding "Grade Now" was to remove "Set Due Date" since FSRS can't properly work with it?
author_association: CONTRIBUTOR
user: login: JSchoreels
created_at: 2025-03-04T13:35:07Z
body: Would it be feasible to make it accessible through APIs like AnkiConnect so third party tools like Yomitan could be used to mark a word as "Forgotten" ? For example, if I'm reading a book, I hover a word I forgot, if I see it's already in Anki and has reviews, I could mark it as "Again" meaning I forgot it, and thus my external review would be counted by the scheduler as a review
author_association: CONTRIBUTOR
user: login: user1823
created_at: 2025-03-04T13:39:57Z
body: > if I'm reading a book, I hover a word I forgot, if I see it's already in Anki and has reviews, I could mark it as "Again" meaning I forgot it, and thus my external review would be counted by the scheduler as a review

Yeah, that's the purpose of adding the Grade now feature.

However, I can't say whether connecting this to third party tools would be easily possible or not.
author_association: CONTRIBUTOR
user: login: YukiNagat0
created_at: 2025-03-04T13:49:52Z
body: > However, I can't say whether connecting this to third party tools would be easily possible or not.

All that’s needed from Anki side is to expose the `grade_now` API (in the programming interface sense, not a web-API). This would allow tools like `Anki Connect` or other Anki add-ons to interact with the feature directly through Python code. Of course, it would be add-on developer work to implement this feature in theirs add-ons by using exposed API.
author_association: CONTRIBUTOR
user: login: YukiNagat0
created_at: 2025-03-05T20:16:12Z
body: **upd**: It’s worth noting that `Anki Connect` already provides a [Graphical Actions](https://git.sr.ht/~foosoft/anki-connect#graphical-actions) API, which allows users to view selected cards. This means `Anki Connect`'s existing API is already capable of handling the task.

For instance, `Yomitan` demonstrates this functionality by enabling users to quickly view duplicates (cards already in their collection) for a specific word. This same mechanism could be adapted to `Grade Now` a card:

1. Scan the word you want to `Grade Now` (via pop-up or Search Page).
2. Click the `View added note` button.
3. Grade the card directly in Anki using the built-in `Grade Now` interface.

Demonstration: 

https://github.com/user-attachments/assets/636bbd8d-f6be-4ece-8529-aee337b7081b


author_association: CONTRIBUTOR
user: login: YukiNagat0
created_at: 2025-03-05T20:41:16Z
body: The advantage of leveraging the [Graphical Actions](https://git.sr.ht/~foosoft/anki-connect#graphical-actions) API is that developers of end-apps (e.g., `Yomitan`) wouldn’t need to create their own custom interface for displaying grades or handling grading. Instead, they can seamlessly redirect users to `Anki`’s native interface, where users can perform a variety of actions on the card: grade it, edit it, delete it, and so on.


author_association: CONTRIBUTOR
user: login: brishtibheja
created_at: 2025-03-06T14:48:32Z
body: > I thought the whole point of adding "Grade Now" was to remove "Set Due Date" since FSRS can't properly work with it?

@Expertium I think people should be allowed to grade the cards when they're using SDD. Or, allow setting a due date when using GN. That was also the initial idea with this feature if I remember correctly, i.e. reschedules will stop having a manual `0` entry making it easier for FSRS to schedule those cards.

I'll leave the matter to Jarrett however. 
author_association: CONTRIBUTOR
user: login: L-M-Sherlock
created_at: 2025-03-06T15:19:58Z
body: To solve the set due date problem, we may require the user to grade before set due date.
author_association: CONTRIBUTOR
user: login: Expertium
created_at: 2025-03-06T15:52:21Z
body: > To solve the set due date problem, we may require the user to grade before set due date.

I feel like we need to merge both GN and SDD into one feature.
author_association: CONTRIBUTOR
user: login: brishtibheja
created_at: 2025-03-07T15:41:25Z
body: > To solve the set due date problem, we may require the user to grade before set due date.

A normal review just before a SDD would also help? And how will people be let known that needs to be done? I feel changes to SDD needs to be made (maybe in a different PR).
author_association: CONTRIBUTOR
user: login: Expertium
created_at: 2025-03-09T12:27:51Z
body: @dae seems like nobody agrees on anything. Let me summarize:
1) We could combine "Grade Now" and "Set Due Date" into the same feature. Not sure how exactly, though.
I think we could ask the user to select a grade and **optionally** an interval length.
If the user only selected a grade, the intervals will be handled by SM-2/FSRS.
If the user selected a grade and provided and interval length as well, we get SDD but with proper grades.
2) We could keep GN and SDD as two separate features, but that would be awkward and confusing,
author_association: CONTRIBUTOR
user: login: brishtibheja
created_at: 2025-03-09T12:38:45Z
body: I don't think it'd be confusing per se. Just that the issues with SDD will still be there for some users. There's a good way to solve those by having users use GN along with SDD, but if they're seperated like this users won't know they've got to do this.
author_association: CONTRIBUTOR
user: login: Danika-Dakika
created_at: 2025-03-09T18:48:00Z
body: [I question whether a PR for one feature is a useful place to discuss removing/altering another feature, but I'll respond here, since the discussion is already underway.]

There's nothing confusing about having Set Due Date and Grade Now as 2 separate features -- they do completely different things. It's great if Grade Now can address some SDD use-cases, specific to _New_ cards, that were challenges for FSRS, but it certainly doesn't cover all of the uses. For instance, Grade Now doesn't allow a user to set a card to be due on a particular date/range. 

My primary use case for SDD is when I've made major changes to a note, even adding New siblings that will be introduced soon, and I don't want to wait months to study the existing Review siblings again. For that, Grade Now would be useless to me. I don't want to _skip_ studying those cards and grade them today -- I want to _actually_ study them, just sooner. 

SDD is also a reasonable technique for addressing a missed-day/vacation backlog: scatter those overdue cards across the next X days. There are better techniques -- but it's a fast and simple alternative for folks who don't want to deal with Filtered decks, and it's better **by far** than the "postpone your whole collection X days"-type add-ons. 
author_association: COLLABORATOR
user: login: user1823
created_at: 2025-03-10T08:38:13Z
body: > Are there situations where it would make sense for FSRS users to use SDD without grading the card?

Currently, I use SDD in only one situation and in that situation, grading the card would be harmful.

I use SDD when I am in the reviewer and encounter a card that I don't want to answer for the next few days (depending on its stability). Basically, I use SDD as a "prolonged bury" feature.

Why I don't want to answer it?
It's because I saw a very related card that day or 1-2 days ago, which is artificially boosting my memory of the answer. If I grade the card, it will be pushed way far in the future and I will likely forget it by then. By using SDD, I "bury" the card for 10-15 days, assuming that the effect of the passive exposure would have vanished till then and I can test myself fairly.

What would happen if I rate it `Good` and then use SDD?
Experience suggests that if I press Good on a card after 1 year and then press Again after 10 days, FSRS gets terribly confused (because FSRS doesn't know that I rated Good only because I read the fact 5 minutes ago).
In fact, in the past, I have had to delete these faulty `Good` ratings from my collection in order to make FSRS work in a reasonable manner. See https://github.com/open-spaced-repetition/fsrs4anki/issues/572#issuecomment-1872799384
author_association: CONTRIBUTOR