Feat/grade now (#3840)

* Feat/grade now

* pass ci

* fix from_queue

* Refactor card answering to support from_queue flag

- Add `from_queue` field to `CardAnswer` struct and proto message
- Modify `answer_card_inner` to handle queue updates based on `from_queue`
- Remove `grade_card` method and consolidate card answering logic
- Update related test cases to set `from_queue` flag

* fix current_changes() called when no op set

* Optimize queue updates for batch card processing

- Refactor `grade_now` to collect processed card IDs first
- Add new `update_queues_for_processed_cards` method for efficient batch queue updates
- Improve queue management by removing entries and updating counts in a single pass
- Remove individual queue update method in favor of batch processing

* pass ci

* keep the same style

* remove ineffective code

* remove unused imports
This commit is contained in:
Jarrett Ye 2025-03-15 18:30:40 +08:00 committed by GitHub
parent 79b6f658c3
commit 0e31efac08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 154 additions and 9 deletions

View file

@ -50,6 +50,7 @@ actions-select = Select
actions-shortcut-key = Shortcut key: { $val } actions-shortcut-key = Shortcut key: { $val }
actions-suspend-card = Suspend Card actions-suspend-card = Suspend Card
actions-set-due-date = Set Due Date actions-set-due-date = Set Due Date
actions-grade-now = Grade Now
actions-answer-card = Answer Card actions-answer-card = Answer Card
actions-unbury-unsuspend = Unbury/Unsuspend actions-unbury-unsuspend = Unbury/Unsuspend
actions-add-deck = Add Deck actions-add-deck = Add Deck

View file

@ -172,6 +172,11 @@ scheduling-set-due-date-done =
[one] Set due date of { $cards } card. [one] Set due date of { $cards } card.
*[other] Set due date of { $cards } cards. *[other] Set due date of { $cards } cards.
} }
scheduling-graded-cards-done =
{ $cards ->
[one] Graded { $cards } card.
*[other] Graded { $cards } cards.
}
scheduling-forgot-cards = scheduling-forgot-cards =
{ $cards -> { $cards ->
[one] Reset { $cards } card. [one] Reset { $cards } card.

View file

@ -36,6 +36,7 @@ service SchedulerService {
rpc ScheduleCardsAsNewDefaults(ScheduleCardsAsNewDefaultsRequest) rpc ScheduleCardsAsNewDefaults(ScheduleCardsAsNewDefaultsRequest)
returns (ScheduleCardsAsNewDefaultsResponse); returns (ScheduleCardsAsNewDefaultsResponse);
rpc SetDueDate(SetDueDateRequest) returns (collection.OpChanges); rpc SetDueDate(SetDueDateRequest) returns (collection.OpChanges);
rpc GradeNow(GradeNowRequest) returns (collection.OpChanges);
rpc SortCards(SortCardsRequest) returns (collection.OpChangesWithCount); rpc SortCards(SortCardsRequest) returns (collection.OpChangesWithCount);
rpc SortDeck(SortDeckRequest) returns (collection.OpChangesWithCount); rpc SortDeck(SortDeckRequest) returns (collection.OpChangesWithCount);
rpc GetSchedulingStates(cards.CardId) returns (SchedulingStates); rpc GetSchedulingStates(cards.CardId) returns (SchedulingStates);
@ -238,6 +239,11 @@ message SetDueDateRequest {
config.OptionalStringConfigKey config_key = 3; config.OptionalStringConfigKey config_key = 3;
} }
message GradeNowRequest {
repeated int64 card_ids = 1;
CardAnswer.Rating rating = 2;
}
message SortCardsRequest { message SortCardsRequest {
repeated int64 card_ids = 1; repeated int64 card_ids = 1;
uint32 starting_from = 2; uint32 starting_from = 2;

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import functools
import json import json
import math import math
import re import re
@ -37,6 +38,7 @@ from aqt.operations.note import remove_notes
from aqt.operations.scheduling import ( from aqt.operations.scheduling import (
bury_cards, bury_cards,
forget_cards, forget_cards,
grade_now,
reposition_new_cards_dialog, reposition_new_cards_dialog,
set_due_date_dialog, set_due_date_dialog,
suspend_cards, suspend_cards,
@ -340,6 +342,7 @@ class Browser(QMainWindow):
qconnect(f.action_Info.triggered, self.showCardInfo) qconnect(f.action_Info.triggered, self.showCardInfo)
qconnect(f.actionReposition.triggered, self.reposition) qconnect(f.actionReposition.triggered, self.reposition)
qconnect(f.action_set_due_date.triggered, self.set_due_date) 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.action_forget.triggered, self.forget_cards)
qconnect(f.actionToggle_Suspend.triggered, self.suspend_selected_cards) qconnect(f.actionToggle_Suspend.triggered, self.suspend_selected_cards)
qconnect(f.action_toggle_bury.triggered, self.bury_selected_cards) qconnect(f.action_toggle_bury.triggered, self.bury_selected_cards)
@ -1080,6 +1083,43 @@ class Browser(QMainWindow):
): ):
op.run_in_background() 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 # Edit: selection
###################################################################### ######################################################################

View file

@ -267,6 +267,7 @@
<addaction name="actionChange_Deck"/> <addaction name="actionChange_Deck"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="action_set_due_date"/> <addaction name="action_set_due_date"/>
<addaction name="action_grade_now"/>
<addaction name="action_forget"/> <addaction name="action_forget"/>
<addaction name="actionReposition"/> <addaction name="actionReposition"/>
<addaction name="separator"/> <addaction name="separator"/>
@ -623,6 +624,14 @@
<string notr="true">Ctrl+Shift+D</string> <string notr="true">Ctrl+Shift+D</string>
</property> </property>
</action> </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"> <action name="action_forget">
<property name="text"> <property name="text">
<string>qt_accel_forget</string> <string>qt_accel_forget</string>

View file

@ -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( def forget_cards(
*, *,
parent: QWidget, parent: QWidget,

View file

@ -32,6 +32,7 @@ pub enum Op {
ScheduleAsNew, ScheduleAsNew,
SetCardDeck, SetCardDeck,
SetDueDate, SetDueDate,
GradeNow,
SetFlag, SetFlag,
SortCards, SortCards,
Suspend, Suspend,
@ -65,6 +66,7 @@ impl Op {
Op::RenameDeck => tr.actions_rename_deck(), Op::RenameDeck => tr.actions_rename_deck(),
Op::ScheduleAsNew => tr.actions_forget_card(), Op::ScheduleAsNew => tr.actions_forget_card(),
Op::SetDueDate => tr.actions_set_due_date(), Op::SetDueDate => tr.actions_set_due_date(),
Op::GradeNow => tr.actions_grade_now(),
Op::Suspend => tr.studying_suspend(), Op::Suspend => tr.studying_suspend(),
Op::UnburyUnsuspend => tr.actions_unbury_unsuspend(), Op::UnburyUnsuspend => tr.actions_unbury_unsuspend(),
Op::UpdateCard => tr.actions_update_card(), Op::UpdateCard => tr.actions_update_card(),

View file

@ -52,6 +52,7 @@ pub struct CardAnswer {
pub answered_at: TimestampMillis, pub answered_at: TimestampMillis,
pub milliseconds_taken: u32, pub milliseconds_taken: u32,
pub custom_data: Option<String>, pub custom_data: Option<String>,
pub from_queue: bool,
} }
impl CardAnswer { impl CardAnswer {
@ -312,7 +313,7 @@ impl Collection {
self.transact(Op::AnswerCard, |col| col.answer_card_inner(answer)) 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 let card = self
.storage .storage
.get_card(answer.card_id)? .get_card(answer.card_id)?
@ -363,14 +364,24 @@ impl Collection {
} }
} }
self.update_queues_after_answering_card( // Handle queue updates based on from_queue flag
&card, if answer.from_queue {
timing, self.update_queues_after_answering_card(
matches!( &card,
answer.new_state, timing,
CardState::Filtered(FilteredState::Preview(PreviewState { finished: true, .. })) 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<()> { fn maybe_bury_siblings(&mut self, card: &Card, config: &DeckConfig) -> Result<()> {
@ -588,6 +599,7 @@ pub mod test_helpers {
answered_at: TimestampMillis::now(), answered_at: TimestampMillis::now(),
milliseconds_taken: 0, milliseconds_taken: 0,
custom_data: None, custom_data: None,
from_queue: true,
})?; })?;
Ok(PostAnswerState { Ok(PostAnswerState {
card_id: queued.card.id, card_id: queued.card.id,

View file

@ -96,6 +96,7 @@ mod test {
answered_at: TimestampMillis::now(), answered_at: TimestampMillis::now(),
milliseconds_taken: 0, milliseconds_taken: 0,
custom_data: None, custom_data: None,
from_queue: true,
})?; })?;
c = col.storage.get_card(c.id)?.unwrap(); c = col.storage.get_card(c.id)?.unwrap();
@ -111,6 +112,7 @@ mod test {
answered_at: TimestampMillis::now(), answered_at: TimestampMillis::now(),
milliseconds_taken: 0, milliseconds_taken: 0,
custom_data: None, custom_data: None,
from_queue: true,
})?; })?;
c = col.storage.get_card(c.id)?.unwrap(); c = col.storage.get_card(c.id)?.unwrap();
assert_eq!(c.queue, CardQueue::PreviewRepeat); assert_eq!(c.queue, CardQueue::PreviewRepeat);
@ -126,6 +128,7 @@ mod test {
answered_at: TimestampMillis::now(), answered_at: TimestampMillis::now(),
milliseconds_taken: 0, milliseconds_taken: 0,
custom_data: None, custom_data: None,
from_queue: true,
})?; })?;
c = col.storage.get_card(c.id)?.unwrap(); c = col.storage.get_card(c.id)?.unwrap();
assert_eq!(c.queue, CardQueue::DayLearn); assert_eq!(c.queue, CardQueue::DayLearn);

View file

@ -8,6 +8,7 @@ use rand::distributions::Distribution;
use rand::distributions::Uniform; use rand::distributions::Uniform;
use regex::Regex; use regex::Regex;
use super::answering::CardAnswer;
use crate::card::Card; use crate::card::Card;
use crate::card::CardId; use crate::card::CardId;
use crate::card::CardQueue; use crate::card::CardQueue;
@ -143,6 +144,34 @@ impl Collection {
Ok(()) 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)] #[cfg(test)]

View file

@ -21,6 +21,7 @@ impl From<anki_proto::scheduler::CardAnswer> for CardAnswer {
answered_at: TimestampMillis(answer.answered_at_millis), answered_at: TimestampMillis(answer.answered_at_millis),
milliseconds_taken: answer.milliseconds_taken, milliseconds_taken: answer.milliseconds_taken,
custom_data, custom_data,
from_queue: true,
} }
} }
} }

View file

@ -165,6 +165,14 @@ impl crate::services::SchedulerService for Collection {
self.set_due_date(&cids, &days, config).map(Into::into) 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( fn sort_cards(
&mut self, &mut self,
input: scheduler::SortCardsRequest, input: scheduler::SortCardsRequest,