diff --git a/proto/anki/collection.proto b/proto/anki/collection.proto index 33041361334..0ed1a155c84 100644 --- a/proto/anki/collection.proto +++ b/proto/anki/collection.proto @@ -78,6 +78,10 @@ message OpChangesOnly { collection.OpChanges changes = 1; } +message NestedOpChanges { + OpChangesOnly changes = 1; +} + message OpChangesWithCount { OpChanges changes = 1; uint32 count = 2; diff --git a/proto/anki/config.proto b/proto/anki/config.proto index ea115f0fc81..f7a04e38d9d 100644 --- a/proto/anki/config.proto +++ b/proto/anki/config.proto @@ -57,6 +57,7 @@ message ConfigKey { LOAD_BALANCER_ENABLED = 26; FSRS_SHORT_TERM_WITH_STEPS_ENABLED = 27; FSRS_LEGACY_EVALUATE = 28; + NEW_REVIEWER = 29; } enum String { SET_DUE_BROWSER = 0; @@ -120,6 +121,7 @@ message Preferences { uint32 time_limit_secs = 5; bool load_balancer_enabled = 6; bool fsrs_short_term_with_steps_enabled = 7; + bool new_reviewer = 8; } message Editing { bool adding_defaults_to_current_deck = 1; diff --git a/proto/anki/frontend.proto b/proto/anki/frontend.proto index 1d733a369bb..bac47c8d222 100644 --- a/proto/anki/frontend.proto +++ b/proto/anki/frontend.proto @@ -10,6 +10,7 @@ package anki.frontend; import "anki/scheduler.proto"; import "anki/generic.proto"; import "anki/search.proto"; +import "anki/card_rendering.proto"; service FrontendService { // Returns values from the reviewer @@ -30,6 +31,10 @@ service FrontendService { // Save colour picker's custom colour palette rpc SaveCustomColours(generic.Empty) returns (generic.Empty); + + // Plays the listed AV tags + rpc PlayAVTags(PlayAVTagsRequest) returns (generic.Empty); + rpc ReviewerAction(ReviewerActionRequest) returns (generic.Empty); } service BackendFrontendService {} @@ -43,3 +48,35 @@ message SetSchedulingStatesRequest { string key = 1; scheduler.SchedulingStates states = 2; } + +message PlayAVTagsRequest { + repeated card_rendering.AVTag tags = 1; +} + +message ReviewerActionRequest { + enum ReviewerAction { + // Menus + EditCurrent = 0; + SetDueDate = 1; + CardInfo = 2; + PreviousCardInfo = 3; + CreateCopy = 4; + // Reset + Forget = 5; + // Preset Options + Options = 6; + // "Congratulations" + Overview = 7; + + // Audio + PauseAudio = 9; + SeekBackward = 10; + SeekForward = 11; + RecordVoice = 12; + ReplayRecorded = 13; + }; + + ReviewerAction menu = 1; + // In case the card isn't set in a next_card_data intercept function + optional int64 current_card_id = 2; +} \ No newline at end of file diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto index 34b350642c3..9a179ad9318 100644 --- a/proto/anki/scheduler.proto +++ b/proto/anki/scheduler.proto @@ -13,10 +13,12 @@ import "anki/decks.proto"; import "anki/collection.proto"; import "anki/config.proto"; import "anki/deck_config.proto"; +import "anki/card_rendering.proto"; service SchedulerService { rpc GetQueuedCards(GetQueuedCardsRequest) returns (QueuedCards); rpc AnswerCard(CardAnswer) returns (collection.OpChanges); + rpc NextCardData(NextCardDataRequest) returns (NextCardDataResponse); rpc SchedTimingToday(generic.Empty) returns (SchedTimingTodayResponse); rpc StudiedToday(generic.Empty) returns (generic.String); rpc StudiedTodayMessage(StudiedTodayMessageRequest) returns (generic.String); @@ -285,6 +287,61 @@ message CardAnswer { uint32 milliseconds_taken = 6; } +message NextCardDataRequest { + optional CardAnswer answer = 1; +} + +message NextCardDataResponse { + message TypedAnswer { + string text = 1; + string args = 2; + } + + message TimerPreferences { + uint32 max_time_ms = 1; + bool stop_on_answer = 2; + } + + message PartialTemplate { + repeated card_rendering.RenderedTemplateNode front = 1; + repeated card_rendering.RenderedTemplateNode back = 2; + } + + message NextCardData { + QueuedCards queue = 1; + bool showDue = 2; + + string front = 3; + string back = 4; + string css = 5; + string body_class = 6; + bool autoplay = 7; + bool marked = 13; + optional TypedAnswer typed_answer = 12; + optional TimerPreferences timer = 14; + // TODO: Is it worth setting up some sort of "ReviewerPreferences" endpoint + // akin to GetGraphPreferences Also should this reviewer setting be moved to + // a config bool rather than config.meta + bool accept_enter = 15; + + repeated card_rendering.AVTag question_av_tags = 8; + repeated card_rendering.AVTag answer_av_tags = 9; + + float autoAdvanceQuestionSeconds = 16; + float autoAdvanceAnswerSeconds = 17; + bool autoAdvanceWaitForAudio = 20; + + deck_config.DeckConfig.Config.QuestionAction autoAdvanceQuestionAction = 18; + deck_config.DeckConfig.Config.AnswerAction autoAdvanceAnswerAction = 19; + + optional PartialTemplate partialTemplate = 11; + } + + optional NextCardData next_card = 1; + // For media pre-loading. The fields of the note after next_card. + string preload = 2; +} + message CustomStudyRequest { message Cram { enum CramKind { diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 60360470cba..fb50f069121 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -35,6 +35,7 @@ UndoStatus = collection_pb2.UndoStatus OpChanges = collection_pb2.OpChanges OpChangesOnly = collection_pb2.OpChangesOnly +NestedOpChanges = collection_pb2.NestedOpChanges OpChangesWithCount = collection_pb2.OpChangesWithCount OpChangesWithId = collection_pb2.OpChangesWithId OpChangesAfterUndo = collection_pb2.OpChangesAfterUndo diff --git a/rslib/proto/typescript.rs b/rslib/proto/typescript.rs index 4e941a0cacc..3b70efdfc79 100644 --- a/rslib/proto/typescript.rs +++ b/rslib/proto/typescript.rs @@ -12,6 +12,7 @@ use anki_proto_gen::Method; use anyhow::Result; use inflections::Inflect; use itertools::Itertools; +use prost_reflect::MessageDescriptor; pub(crate) fn write_ts_interface(services: &[BackendService]) -> Result<()> { let root = Path::new("../../out/ts/lib/generated"); @@ -73,14 +74,16 @@ fn write_ts_method( input_type, output_type, comments, + op_changes_type, }: &MethodDetails, out: &mut String, ) { + let op_changes_type = *op_changes_type as u8; let comments = format_comments(comments); writeln!( out, r#"{comments}export async function {method_name}(input: PlainMessage<{input_type}>, options?: PostProtoOptions): Promise<{output_type}> {{ - return await postProto("{method_name}", new {input_type}(input), {output_type}, options); + return await postProto("{method_name}", new {input_type}(input), {output_type}, options, {op_changes_type}); }}"# ).unwrap() } @@ -92,11 +95,21 @@ fn format_comments(comments: &Option) -> String { .unwrap_or_default() } +#[derive(Clone, Copy)] +#[repr(u8)] +enum OpChangesType { + None = 0, + OpChanges = 1, + OpChangesOnly = 2, + NestedOpChanges = 3, +} + struct MethodDetails { method_name: String, input_type: String, output_type: String, comments: Option, + op_changes_type: OpChangesType, } impl MethodDetails { @@ -105,12 +118,43 @@ impl MethodDetails { let input_type = full_name_to_imported_reference(method.proto.input().full_name()); let output_type = full_name_to_imported_reference(method.proto.output().full_name()); let comments = method.comments.clone(); + let op_changes_type = + get_op_changes_type(&method.proto.output(), &method.proto.output(), 1); Self { method_name: name, input_type, output_type, comments, + op_changes_type, + } + } +} + +fn get_op_changes_type( + root_message: &MessageDescriptor, + message: &MessageDescriptor, + level: u8, +) -> OpChangesType { + if message.full_name() == "anki.collection.OpChanges" { + match level { + 0 => OpChangesType::None, + 1 => OpChangesType::OpChanges, + 2 => OpChangesType::OpChangesOnly, + 3 => OpChangesType::NestedOpChanges, + _ => panic!( + "unhandled op changes level for message {}: {}", + root_message.full_name(), + level + ), + } + } else if let Some(field) = message.get_field(1) { + if let Some(field_message) = field.kind().as_message() { + get_op_changes_type(root_message, field_message, level + 1) + } else { + OpChangesType::None } + } else { + OpChangesType::None } } diff --git a/rslib/src/backend/config.rs b/rslib/src/backend/config.rs index b6e81ce2ad7..88bc34601f1 100644 --- a/rslib/src/backend/config.rs +++ b/rslib/src/backend/config.rs @@ -40,6 +40,7 @@ impl From for BoolKey { BoolKeyProto::LoadBalancerEnabled => BoolKey::LoadBalancerEnabled, BoolKeyProto::FsrsShortTermWithStepsEnabled => BoolKey::FsrsShortTermWithStepsEnabled, BoolKeyProto::FsrsLegacyEvaluate => BoolKey::FsrsLegacyEvaluate, + BoolKeyProto::NewReviewer => BoolKey::NewReviewer, } } } @@ -57,8 +58,14 @@ impl From for StringKey { impl crate::services::ConfigService for Collection { fn get_config_json(&mut self, input: generic::String) -> Result { - let val: Option = self.get_config_optional(input.val.as_str()); - val.or_not_found(input.val) + let key = input.val.as_str(); + let val: Option = self.get_config_optional(key); + let default = match key { + "reviewerStorage" => Some(serde_json::from_str("{}").unwrap()), + _ => None, + }; + val.or(default) + .or_not_found(key) .and_then(|v| serde_json::to_vec(&v).map_err(Into::into)) .map(Into::into) } diff --git a/rslib/src/card_rendering/service.rs b/rslib/src/card_rendering/service.rs index 73f8302cacf..f9461821d34 100644 --- a/rslib/src/card_rendering/service.rs +++ b/rslib/src/card_rendering/service.rs @@ -180,7 +180,7 @@ impl crate::services::CardRenderingService for Collection { } } -fn rendered_nodes_to_proto( +pub(crate) fn rendered_nodes_to_proto( nodes: Vec, ) -> Vec { nodes diff --git a/rslib/src/config/bool.rs b/rslib/src/config/bool.rs index 1be9b255698..7fc836a6b97 100644 --- a/rslib/src/config/bool.rs +++ b/rslib/src/config/bool.rs @@ -42,6 +42,7 @@ pub enum BoolKey { FsrsLegacyEvaluate, LoadBalancerEnabled, FsrsShortTermWithStepsEnabled, + NewReviewer, #[strum(to_string = "normalize_note_text")] NormalizeNoteText, #[strum(to_string = "dayLearnFirst")] diff --git a/rslib/src/preferences.rs b/rslib/src/preferences.rs index 96be8e46120..fa760baefcf 100644 --- a/rslib/src/preferences.rs +++ b/rslib/src/preferences.rs @@ -101,6 +101,7 @@ impl Collection { load_balancer_enabled: self.get_config_bool(BoolKey::LoadBalancerEnabled), fsrs_short_term_with_steps_enabled: self .get_config_bool(BoolKey::FsrsShortTermWithStepsEnabled), + new_reviewer: self.get_config_bool(BoolKey::NewReviewer), }) } @@ -125,6 +126,7 @@ impl Collection { BoolKey::FsrsShortTermWithStepsEnabled, s.fsrs_short_term_with_steps_enabled, )?; + self.set_config_bool_inner(BoolKey::NewReviewer, settings.new_reviewer)?; Ok(()) } diff --git a/rslib/src/scheduler/service/mod.rs b/rslib/src/scheduler/service/mod.rs index 9f42a79f7fb..7a35032f23e 100644 --- a/rslib/src/scheduler/service/mod.rs +++ b/rslib/src/scheduler/service/mod.rs @@ -4,9 +4,15 @@ mod answering; mod states; +use std::sync::LazyLock; + use anki_proto::cards; use anki_proto::generic; use anki_proto::scheduler; +use anki_proto::scheduler::next_card_data_response::NextCardData; +use anki_proto::scheduler::next_card_data_response::PartialTemplate; +use anki_proto::scheduler::next_card_data_response::TimerPreferences; +use anki_proto::scheduler::next_card_data_response::TypedAnswer; use anki_proto::scheduler::ComputeFsrsParamsResponse; use anki_proto::scheduler::ComputeMemoryStateResponse; use anki_proto::scheduler::ComputeOptimalRetentionResponse; @@ -14,6 +20,8 @@ use anki_proto::scheduler::FsrsBenchmarkResponse; use anki_proto::scheduler::FuzzDeltaRequest; use anki_proto::scheduler::FuzzDeltaResponse; use anki_proto::scheduler::GetOptimalRetentionParametersResponse; +use anki_proto::scheduler::NextCardDataRequest; +use anki_proto::scheduler::NextCardDataResponse; use anki_proto::scheduler::SimulateFsrsReviewRequest; use anki_proto::scheduler::SimulateFsrsReviewResponse; use anki_proto::scheduler::SimulateFsrsWorkloadResponse; @@ -21,15 +29,20 @@ use fsrs::ComputeParametersInput; use fsrs::FSRSItem; use fsrs::FSRSReview; use fsrs::FSRS; +use regex::Regex; use crate::backend::Backend; +use crate::card_rendering::service::rendered_nodes_to_proto; +use crate::cloze::extract_cloze_for_typing; use crate::prelude::*; use crate::scheduler::fsrs::params::ComputeParamsRequest; use crate::scheduler::new::NewCardDueOrder; use crate::scheduler::states::CardState; use crate::scheduler::states::SchedulingStates; use crate::search::SortMode; +use crate::services::NotesService; use crate::stats::studied_today; +use crate::template::RenderedNode; impl crate::services::SchedulerService for Collection { /// This behaves like _updateCutoff() in older code - it also unburies at @@ -382,6 +395,131 @@ impl crate::services::SchedulerService for Collection { delta_days: self.get_fuzz_delta(input.card_id.into(), input.interval)?, }) } + + fn next_card_data(&mut self, req: NextCardDataRequest) -> Result { + if let Some(answer) = req.answer { + self.answer_card(&mut answer.into())?; + } + let mut queue = self.get_queued_cards(2, false)?; + let next_card = queue.cards.first(); + if let Some(next_card) = next_card { + let cid = next_card.card.id; + let deck_config = self.deck_config_for_card(&next_card.card)?.inner; + let note = self.get_note(next_card.card.note_id.into())?; + + let render = self.render_existing_card(cid, false, true)?; + let show_due = self.get_config_bool(BoolKey::ShowIntervalsAboveAnswerButtons); + let show_remaning = self.get_config_bool(BoolKey::ShowRemainingDueCountsInStudy); + + // Typed answer replacements + static ANSWER_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"\[\[type:(.+?:)?(.+?)\]\]").unwrap()); + + const ANSWER_HTML: &str = "
+ +
"; + + let mut q_nodes = render.qnodes; + let typed_answer_parent_node = q_nodes.iter_mut().find_map(|node| { + if let RenderedNode::Text { text } = node { + let mut out = None; + *text = ANSWER_REGEX + .replace(text, |cap: ®ex::Captures<'_>| { + out = Some(( + cap.get(1) + .map(|g| g.as_str().to_string()) + .unwrap_or("".to_string()), + cap[2].to_string(), + )); + ANSWER_HTML + }) + .to_string(); + out + } else { + None + } + }); + + let typed_answer = typed_answer_parent_node + .map(|field| -> Result<(String, String)> { + let notetype = self + .get_notetype(note.notetype_id.into())? + .or_not_found(note.notetype_id)?; + let field_ord = notetype.get_field_ord(&field.1).or_not_found(field.1)?; + let mut correct = note.fields[field_ord].clone(); + if field.0.contains("cloze") { + let card_ord = queue.cards[0].card.template_idx; + correct = extract_cloze_for_typing(&correct, card_ord + 1).to_string() + } + Ok((field.0, correct)) + }) + .transpose()?; + + let marked = note.tags.contains(&"marked".to_string()); + + if !show_remaning { + queue.learning_count = 0; + queue.review_count = 0; + queue.new_count = 0; + } + + let timer = deck_config.show_timer.then_some(TimerPreferences { + max_time_ms: deck_config.cap_answer_time_to_secs * 1000, + stop_on_answer: deck_config.stop_timer_on_answer, + }); + + let preload = queue + .cards + .get(1) + .map(|after_card| -> Result> { + let after_note = self.get_note(after_card.card.note_id.into())?; + Ok(after_note.fields) + }) + .transpose()? + .unwrap_or(vec![]) + .join(""); + + Ok(NextCardDataResponse { + next_card: Some(NextCardData { + queue: Some(queue.into()), + + css: render.css.clone(), + partial_template: Some(PartialTemplate { + front: rendered_nodes_to_proto(q_nodes), + back: rendered_nodes_to_proto(render.anodes), + }), + + show_due, + autoplay: !deck_config.disable_autoplay, + typed_answer: typed_answer.map(|answer| TypedAnswer { + text: answer.1, + args: answer.0, + }), + marked, + timer, + + auto_advance_answer_seconds: deck_config.seconds_to_show_answer, + auto_advance_question_seconds: deck_config.seconds_to_show_question, + auto_advance_wait_for_audio: deck_config.wait_for_audio, + + auto_advance_answer_action: deck_config.answer_action, + auto_advance_question_action: deck_config.question_action, + + // Filled by python + accept_enter: true, + front: "".to_string(), + back: "".to_string(), + body_class: "".to_string(), + question_av_tags: vec![], + answer_av_tags: vec![], + }), + preload, + }) + } else { + Ok(NextCardDataResponse::default()) + } + } } impl crate::services::BackendSchedulerService for Backend {