diff --git a/build/configure/src/web.rs b/build/configure/src/web.rs index ef2d268bb..fd169ac22 100644 --- a/build/configure/src/web.rs +++ b/build/configure/src/web.rs @@ -228,6 +228,17 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> { ":sveltekit" ], )?; + build_page( + "reviewer-inner", + true, + inputs![ + // + ":ts:lib", + ":ts:components", + ":sass", + ":sveltekit" + ], + )?; Ok(()) } diff --git a/ftl/core/preferences.ftl b/ftl/core/preferences.ftl index 23b72f267..a702cca4a 100644 --- a/ftl/core/preferences.ftl +++ b/ftl/core/preferences.ftl @@ -14,6 +14,7 @@ preferences-on-next-sync-force-changes-in = On next sync, force changes in one d preferences-paste-clipboard-images-as-png = Paste clipboard images as PNG preferences-paste-without-shift-key-strips-formatting = Paste without shift key strips formatting preferences-generate-latex-images-automatically = Generate LaTeX images (security risk) +preferences-use-new-reviewer = Use new reviewer preferences-latex-generation-disabled = LaTeX image generation is disabled in the preferences. preferences-periodically-sync-media = Periodically sync media preferences-please-restart-anki-to-complete-language = Please restart Anki to complete language change. diff --git a/proto/anki/config.proto b/proto/anki/config.proto index ea115f0fc..f7a04e38d 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 1d733a369..358edfda6 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,9 @@ 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); } service BackendFrontendService {} @@ -43,3 +47,7 @@ message SetSchedulingStatesRequest { string key = 1; scheduler.SchedulingStates states = 2; } + +message PlayAVTagsRequest { + repeated card_rendering.AVTag tags = 1; +} \ No newline at end of file diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto index 34b350642..dd8aaadb3 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,43 @@ message CardAnswer { uint32 milliseconds_taken = 6; } +message NextCardDataRequest { + optional CardAnswer answer = 1; +} + +message NextCardDataResponse { + message AnswerButton { + CardAnswer.Rating rating = 1; + string due = 2; + } + + message NextCardData { + QueuedCards queue = 1; + repeated AnswerButton answer_buttons = 2; + + string front = 3; + string back = 4; + string css = 5; + string body_class = 6; + bool autoplay = 7; + optional string typed_answer = 12; + optional string typed_answer_args = 13; + + repeated card_rendering.AVTag question_av_tags = 8; + repeated card_rendering.AVTag answer_av_tags = 9; + + // TODO: We can probably make this a little faster by using oneof and + // preventing the partial_front and back being sent to svelte where it isn't + // used. Alternatively we can use a completely different message for both + // Rust -> Python and the Python -> Svelte though this would be more + // complicated to implement. + repeated card_rendering.RenderedTemplateNode partial_front = 10; + repeated card_rendering.RenderedTemplateNode partial_back = 11; + } + + optional NextCardData next_card = 1; +} + message CustomStudyRequest { message Cram { enum CramKind { diff --git a/qt/aqt/data/web/css/reviewer-bottom.scss b/qt/aqt/data/web/css/reviewer-bottom.scss index 59098a5fb..8368f4da8 100644 --- a/qt/aqt/data/web/css/reviewer-bottom.scss +++ b/qt/aqt/data/web/css/reviewer-bottom.scss @@ -81,4 +81,4 @@ button { #outer { border-top-color: color(border-subtle); } -} +} \ No newline at end of file diff --git a/qt/aqt/forms/preferences.ui b/qt/aqt/forms/preferences.ui index 0035e1f42..2a59b5f9d 100644 --- a/qt/aqt/forms/preferences.ui +++ b/qt/aqt/forms/preferences.ui @@ -451,6 +451,19 @@ + + + + + 0 + 0 + + + + preferences_use_new_reviewer + + + diff --git a/qt/aqt/main.py b/qt/aqt/main.py index c707d1b2a..98d4409a3 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -255,13 +255,11 @@ class AnkiQt(QMainWindow): # screens self.setupDeckBrowser() self.setupOverview() - self.setupReviewer() + # self.setupReviewer() def finish_ui_setup(self) -> None: "Actions that are deferred until after add-on loading." self.toolbar.draw() - # add-ons are only available here after setupAddons - gui_hooks.reviewer_did_init(self.reviewer) def setupProfileAfterWebviewsLoaded(self) -> None: for w in (self.web, self.bottomWeb): @@ -679,6 +677,8 @@ class AnkiQt(QMainWindow): # dump error to stderr so it gets picked up by errors.py traceback.print_exc() + self.setupReviewer(self.backend.get_config_bool(Config.Bool.NEW_REVIEWER)) + return True def _loadCollection(self) -> None: @@ -1074,10 +1074,13 @@ title="{}" {}>{}""".format( self.overview = Overview(self) - def setupReviewer(self) -> None: - from aqt.reviewer import Reviewer + def setupReviewer(self, new: bool) -> None: + from aqt.reviewer import Reviewer, SvelteReviewer - self.reviewer = Reviewer(self) + self.reviewer = SvelteReviewer(self) if new else Reviewer(self) + + # add-ons are only available here after setupAddons + gui_hooks.reviewer_did_init(self.reviewer) # Syncing ########################################################################## diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index bedf23e5b..aab4e58c0 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -28,9 +28,18 @@ import aqt import aqt.main import aqt.operations from anki import hooks +from anki.cards import Card from anki.collection import OpChanges, OpChangesOnly, Progress, SearchNode from anki.decks import UpdateDeckConfigs +from anki.frontend_pb2 import PlayAVTagsRequest from anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest +from anki.scheduler_pb2 import NextCardDataResponse +from anki.template import ( + PartiallyRenderedCard, + TemplateRenderContext, + apply_custom_filters, + av_tags_to_native, +) from anki.utils import dev_mode from aqt.changenotetype import ChangeNotetypeDialog from aqt.deckoptions import DeckOptionsDialog @@ -38,6 +47,8 @@ from aqt.operations import on_op_finished from aqt.operations.deck import update_deck_configs as update_deck_configs_op from aqt.progress import ProgressUpdate from aqt.qt import * +from aqt.sound import play_tags +from aqt.theme import ThemeManager from aqt.utils import aqt_data_path, show_warning, tr # https://forums.ankiweb.net/t/anki-crash-when-using-a-specific-deck/22266 @@ -363,6 +374,7 @@ def is_sveltekit_page(path: str) -> bool: "import-csv", "import-page", "image-occlusion", + "reviewer", ] @@ -637,6 +649,55 @@ def save_custom_colours() -> bytes: return b"" +theme_manager = ThemeManager() + + +def next_card_data() -> bytes: + raw = aqt.mw.col._backend.next_card_data_raw(request.data) + data = NextCardDataResponse.FromString(raw) + backend_card = data.next_card.queue.cards[0].card + card = Card(aqt.mw.col, backend_card=backend_card) + + ctx = TemplateRenderContext.from_existing_card(card, False) + + qside = apply_custom_filters( + PartiallyRenderedCard.nodes_from_proto(data.next_card.partial_front), + ctx, + None, + ) + aside = apply_custom_filters( + PartiallyRenderedCard.nodes_from_proto(data.next_card.partial_back), + ctx, + qside, + ) + + q_avtags = ctx.col()._backend.extract_av_tags(text=qside, question_side=True) + a_avtags = ctx.col()._backend.extract_av_tags(text=aside, question_side=False) + + # Assumes the av tags are empty in the original response + data.next_card.question_av_tags.extend(q_avtags.av_tags) + data.next_card.answer_av_tags.extend(a_avtags.av_tags) + + qside = q_avtags.text + aside = a_avtags.text + + qside = aqt.mw.prepare_card_text_for_display(qside) + aside = aqt.mw.prepare_card_text_for_display(aside) + + data.next_card.front = qside + data.next_card.back = aside + # Night mode is handled by the frontend so that it works with the browsers theme if used outside of anki. + # Perhaps the OS class should be handled this way too? + data.next_card.body_class = theme_manager.body_classes_for_card_ord(card.ord, False) + + return data.SerializeToString() + + +def play_avtags(): + req = PlayAVTagsRequest.FromString(request.data) + play_tags(av_tags_to_native(req.tags)) + + post_handler_list = [ congrats_info, get_deck_configs_for_update, @@ -653,6 +714,8 @@ post_handler_list = [ deck_options_require_close, deck_options_ready, save_custom_colours, + next_card_data, + play_avtags, ] @@ -698,6 +761,9 @@ exposed_backend_list = [ # DeckConfigService "get_ignored_before_count", "get_retention_workload", + # CardsService + "set_flag", + "compare_answer", ] diff --git a/qt/aqt/preferences.py b/qt/aqt/preferences.py index 939dd8c2c..8895becb1 100644 --- a/qt/aqt/preferences.py +++ b/qt/aqt/preferences.py @@ -138,6 +138,7 @@ class Preferences(QDialog): form.showProgress.setChecked(reviewing.show_remaining_due_counts) form.showPlayButtons.setChecked(not reviewing.hide_audio_play_buttons) form.interrupt_audio.setChecked(reviewing.interrupt_audio_when_answering) + form.new_reviewer.setChecked(reviewing.new_reviewer) editing = self.prefs.editing form.useCurrent.setCurrentIndex( @@ -173,6 +174,8 @@ class Preferences(QDialog): reviewing.time_limit_secs = form.timeLimit.value() * 60 reviewing.hide_audio_play_buttons = not self.form.showPlayButtons.isChecked() reviewing.interrupt_audio_when_answering = self.form.interrupt_audio.isChecked() + reviewing.new_reviewer = form.new_reviewer.isChecked() + aqt.mw.setupReviewer(reviewing.new_reviewer) editing = self.prefs.editing editing.adding_defaults_to_current_deck = not form.useCurrent.currentIndex() diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 6d68f9e3a..f8cb9a754 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -1233,6 +1233,79 @@ timerStopped = false; setFlag = set_flag_on_current_card +class SvelteReviewer(Reviewer): + def _answerButtons(self) -> str: + default = self._defaultEase() + + assert isinstance(self.mw.col.sched, V3Scheduler) + labels = self.mw.col.sched.describe_next_states(self._v3.states) + + def but(i: int, label: str): + if i == default: + id = "defease" + else: + id = "" + due = self._buttonTime(i, v3_labels=labels) + key = ( + tr.actions_shortcut_key(val=aqt.mw.pm.get_answer_key(i)) + if aqt.mw.pm.get_answer_key(i) + else "" + ) + return { + "id": id, + "key": key, + "i": i, + "label": label, + "due": due, + } + + return [but(ease, label) for ease, label in self._answerButtonList()] # type: ignore + + def refresh_if_needed(self): + if self._refresh_needed: + self.mw.fade_in_webview() + self.web.eval("if (anki) {anki.changeReceived()}") + self._refresh_needed = None + + def show(self) -> None: + self._initWeb() + + def _remaining(self) -> str: + if not self.mw.col.conf["dueCounts"]: + return "" + + idx, counts = self._v3.counts() + self.web.eval(f"_updateRemaining({json.dumps(counts)},{idx})") + return "" + + def _showAnswerButton(self) -> None: + if self.card.should_show_timer(): + maxTime = self.card.time_limit() / 1000 + else: + maxTime = 0 + self._remaining() + self.web.eval('showQuestion("",%d);' % (maxTime)) + + def _buttonTime(self, i: int, v3_labels: Sequence[str]) -> str: + return v3_labels[i - 1] if self.mw.col.conf["estTimes"] else "" + + def _linkHandler(self, url: str) -> None: + pass + + def _initWeb(self) -> None: + self._reps = 0 + # hide the bottom bar + self.bottom.web.setHtml("") + # main window + self.web.load_sveltekit_page("reviewer") + # block default drag & drop behavior while allowing drop events to be received by JS handlers + self.web.allow_drops = True + self.web.eval("_blockDefaultDragDropBehavior();") + + def _shortcutKeys(self) -> Sequence[tuple[str, Callable] | tuple[Qt.Key, Callable]]: + return [] + + # if the last element is a comment, then the RUN_STATE_MUTATION code # breaks due to the comment wrongly commenting out python code. # To prevent this we put the js code on a separate line diff --git a/qt/aqt/sound.py b/qt/aqt/sound.py index f54ebd3e8..2a2169079 100644 --- a/qt/aqt/sound.py +++ b/qt/aqt/sound.py @@ -925,13 +925,12 @@ def play_clicked_audio(pycmd: str, card: Card) -> None: """eg. if pycmd is 'play:q:0', play the first audio on the question side.""" play, context, str_idx = pycmd.split(":") idx = int(str_idx) - if context == "q": - tags = card.question_av_tags() - else: - tags = card.answer_av_tags() - av_player.play_tags([tags[idx]]) + tags = card.question_av_tags() if context == "q" else card.answer_av_tags() + play_tags([tags[idx]]) +play_tags = av_player.play_tags + # Init defaults ########################################################################## diff --git a/qt/aqt/toolbar.py b/qt/aqt/toolbar.py index be547b5ba..7c617483f 100644 --- a/qt/aqt/toolbar.py +++ b/qt/aqt/toolbar.py @@ -211,7 +211,11 @@ class BottomWebView(ToolbarWebView): def animate_height(self, height: int) -> None: self.web_height = height - if self.mw.pm.reduce_motion() or height == self.height(): + if ( + self.mw.pm.reduce_motion() + or self.mw.col.conf.get("newReviewer") + or height == self.height() + ): self.setFixedHeight(height) else: # Collapse/Expand animation diff --git a/qt/aqt/webview.py b/qt/aqt/webview.py index 95d84c00e..fefbae17c 100644 --- a/qt/aqt/webview.py +++ b/qt/aqt/webview.py @@ -142,6 +142,7 @@ class AnkiWebPage(QWebEnginePage): AnkiWebViewKind.IMPORT_ANKI_PACKAGE, AnkiWebViewKind.IMPORT_CSV, AnkiWebViewKind.IMPORT_LOG, + AnkiWebViewKind.MAIN, ) global _profile_with_api_access, _profile_without_api_access diff --git a/rslib/src/backend/config.rs b/rslib/src/backend/config.rs index b6e81ce2a..9bc1140a2 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, } } } diff --git a/rslib/src/card_rendering/service.rs b/rslib/src/card_rendering/service.rs index 73f8302ca..f9461821d 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 c76787cb0..a5ba0e70f 100644 --- a/rslib/src/config/bool.rs +++ b/rslib/src/config/bool.rs @@ -44,6 +44,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 96be8e461..fa760baef 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 9f42a79f7..d535d8ae9 100644 --- a/rslib/src/scheduler/service/mod.rs +++ b/rslib/src/scheduler/service/mod.rs @@ -4,9 +4,13 @@ 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::AnswerButton; +use anki_proto::scheduler::next_card_data_response::NextCardData; use anki_proto::scheduler::ComputeFsrsParamsResponse; use anki_proto::scheduler::ComputeMemoryStateResponse; use anki_proto::scheduler::ComputeOptimalRetentionResponse; @@ -14,6 +18,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 +27,19 @@ 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::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 +392,89 @@ 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 queue = self.get_queued_cards(1, false)?; + let next_card = queue.cards.first(); + if let Some(next_card) = next_card { + let cid = next_card.card.id; + + let render = self.render_existing_card(cid, false, true)?; + + let answer_buttons = self + .describe_next_states(&next_card.states)? + .into_iter() + .enumerate() + .map(|(i, due)| AnswerButton { + rating: i as i32, + due, + }) + .collect(); + + let config = self.deck_config_for_card(&next_card.card)?; + + // 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()), + cap[2].to_string(), + )); + ANSWER_HTML + }) + .to_string(); + out + } else { + None + } + }); + + let typed_answer = typed_answer_parent_node.as_ref().map(|field| { + let note = self.get_note(next_card.card.note_id.into()).unwrap(); + let notetype = self.get_notetype(note.notetype_id.into()).unwrap().unwrap(); + note.fields[notetype.get_field_ord(&field.1).unwrap()].clone() + }); + + Ok(NextCardDataResponse { + next_card: Some(NextCardData { + queue: Some(queue.into()), + + css: render.css.clone(), + partial_front: rendered_nodes_to_proto(q_nodes), + partial_back: rendered_nodes_to_proto(render.anodes), + + answer_buttons, + autoplay: !config.inner.disable_autoplay, + typed_answer, + typed_answer_args: typed_answer_parent_node.and_then(|v| v.0), + + // Filled by python + front: "".to_string(), + back: "".to_string(), + body_class: "".to_string(), + question_av_tags: vec![], + answer_av_tags: vec![], + }), + }) + } else { + Ok(NextCardDataResponse::default()) + } + } } impl crate::services::BackendSchedulerService for Backend { diff --git a/ts/reviewer/index.ts b/ts/reviewer/index.ts index d0370cbc9..63771f0b0 100644 --- a/ts/reviewer/index.ts +++ b/ts/reviewer/index.ts @@ -104,7 +104,7 @@ async function setInnerHTML(element: Element, html: string): Promise { } } -const renderError = (type: string) => (error: unknown): string => { +export const renderError = (type: string) => (error: unknown): string => { const errorMessage = String(error).substring(0, 2000); let errorStack: string; if (error instanceof Error) { diff --git a/ts/routes/base.scss b/ts/routes/base.scss index c5eb426a6..58480c159 100644 --- a/ts/routes/base.scss +++ b/ts/routes/base.scss @@ -1,4 +1,4 @@ -@import "$lib/sass/base"; +@import "../lib/sass/base"; // override Bootstrap transition duration $carousel-transition: var(--transition); @@ -11,8 +11,8 @@ $carousel-transition: var(--transition); @import "bootstrap/scss/close"; @import "bootstrap/scss/alert"; @import "bootstrap/scss/badge"; -@import "$lib/sass/bootstrap-forms"; -@import "$lib/sass/bootstrap-tooltip"; +@import "../lib/sass/bootstrap-forms"; +@import "../lib/sass/bootstrap-tooltip"; input[type="text"], input[type="date"], diff --git a/ts/routes/reviewer-inner/index.ts b/ts/routes/reviewer-inner/index.ts new file mode 100644 index 000000000..a4c1df522 --- /dev/null +++ b/ts/routes/reviewer-inner/index.ts @@ -0,0 +1,96 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +import "../base.scss"; +import "../../reviewer/reviewer.scss"; +import "mathjax/es5/tex-chtml-full.js"; +import { renderError } from "../../reviewer"; +import { enableNightMode } from "../reviewer/reviewer"; +import type { ReviewerRequest } from "../reviewer/reviewerRequest"; +import type { InnerReviewerRequest } from "./innerReviewerRequest"; + +function postParentMessage(message: ReviewerRequest) { + window.parent.postMessage( + message, + "*", + ); +} + +declare const MathJax: any; +const urlParams = new URLSearchParams(location.search); + +const style = document.createElement("style"); +document.head.appendChild(style); + +addEventListener("message", async (e: MessageEvent) => { + switch (e.data.type) { + case "html": { + document.body.innerHTML = e.data.value; + if (e.data.css) { + style.innerHTML = e.data.css; + } + if (e.data.bodyclass) { + document.body.className = e.data.bodyclass; + const theme = urlParams.get("nightMode"); + if (theme !== null) { + enableNightMode(); + document.body.classList.add("night_mode"); + document.body.classList.add("nightMode"); + } + } + + // wait for mathjax to ready + await MathJax.startup.promise + .then(() => { + // clear MathJax buffers from previous typesets + MathJax.typesetClear(); + + return MathJax.typesetPromise([document.body]); + }) + .catch(renderError("MathJax")); + + break; + } + default: { + console.warn(`Unknown message type: ${e.data.type}`); + break; + } + } +}); + +addEventListener("keydown", (e) => { + if (e.key === "Enter") { + postParentMessage({ type: "keypress", key: " " }); + } else if ( + e.key.length == 1 && "1234 ".includes(e.key) + && !document.activeElement?.matches("input[type=text], input[type=number], textarea") + ) { + postParentMessage({ type: "keypress", key: e.key }); + } +}); + +const base = document.createElement("base"); +base.href = "/"; +document.head.appendChild(base); + +function pycmd(cmd: string) { + const match = cmd.match(/play:(q|a):(\d+)/); + if (match) { + const [_, context, index] = match; + postParentMessage({ + type: "audio", + answerSide: context === "a", + index: parseInt(index), + }); + } +} +globalThis.pycmd = pycmd; + +function _typeAnsPress() { + const elem = document.getElementById("typeans")! as HTMLInputElement; + let key = (window.event as KeyboardEvent).key; + key = key.length == 1 ? key : ""; + postParentMessage( + { type: "typed", value: elem.value + key }, + ); +} +globalThis._typeAnsPress = _typeAnsPress; diff --git a/ts/routes/reviewer-inner/innerReviewerRequest.ts b/ts/routes/reviewer-inner/innerReviewerRequest.ts new file mode 100644 index 000000000..16afcbb88 --- /dev/null +++ b/ts/routes/reviewer-inner/innerReviewerRequest.ts @@ -0,0 +1,10 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +interface HtmlMessage { + type: "html"; + value: string; + css?: string; + bodyclass?: string; +} + +export type InnerReviewerRequest = HtmlMessage; diff --git a/ts/routes/reviewer/+page.svelte b/ts/routes/reviewer/+page.svelte new file mode 100644 index 000000000..6b1b0b06f --- /dev/null +++ b/ts/routes/reviewer/+page.svelte @@ -0,0 +1,30 @@ + + + +
+ + +
+ + diff --git a/ts/routes/reviewer/Reviewer.svelte b/ts/routes/reviewer/Reviewer.svelte new file mode 100644 index 000000000..7c958ecea --- /dev/null +++ b/ts/routes/reviewer/Reviewer.svelte @@ -0,0 +1,37 @@ + + + +
+ +
+ + diff --git a/ts/routes/reviewer/index.ts b/ts/routes/reviewer/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/ts/routes/reviewer/reviewer-bottom/AnswerButton.svelte b/ts/routes/reviewer/reviewer-bottom/AnswerButton.svelte new file mode 100644 index 000000000..80afe363f --- /dev/null +++ b/ts/routes/reviewer/reviewer-bottom/AnswerButton.svelte @@ -0,0 +1,37 @@ + + + + + {#if info.due} + {info.due} + {:else} +   + {/if} + + + + diff --git a/ts/routes/reviewer/reviewer-bottom/More.svelte b/ts/routes/reviewer/reviewer-bottom/More.svelte new file mode 100644 index 000000000..d2f924ddd --- /dev/null +++ b/ts/routes/reviewer/reviewer-bottom/More.svelte @@ -0,0 +1,75 @@ + + + + + + +
+ + { + showFlags = !showFlags; + }} + > + {tr.studyingFlagCard()} + +
+ {#each flags as flag, i} + changeFlag(i + 1)} + > + {flag.colour} + + {/each} +
+
+
+
+ + diff --git a/ts/routes/reviewer/reviewer-bottom/MoreItem.svelte b/ts/routes/reviewer/reviewer-bottom/MoreItem.svelte new file mode 100644 index 000000000..88a0f4b09 --- /dev/null +++ b/ts/routes/reviewer/reviewer-bottom/MoreItem.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/ts/routes/reviewer/reviewer-bottom/MoreSubmenu.svelte b/ts/routes/reviewer/reviewer-bottom/MoreSubmenu.svelte new file mode 100644 index 000000000..463a66b38 --- /dev/null +++ b/ts/routes/reviewer/reviewer-bottom/MoreSubmenu.svelte @@ -0,0 +1,20 @@ + + + +
+ (showFloating = false)}> + + + + + + +
diff --git a/ts/routes/reviewer/reviewer-bottom/Remaining.svelte b/ts/routes/reviewer/reviewer-bottom/Remaining.svelte new file mode 100644 index 000000000..1e1854983 --- /dev/null +++ b/ts/routes/reviewer/reviewer-bottom/Remaining.svelte @@ -0,0 +1,40 @@ + + + + + + {"+"} + + {"+"} + + + + diff --git a/ts/routes/reviewer/reviewer-bottom/RemainingNumber.svelte b/ts/routes/reviewer/reviewer-bottom/RemainingNumber.svelte new file mode 100644 index 000000000..19fa6191c --- /dev/null +++ b/ts/routes/reviewer/reviewer-bottom/RemainingNumber.svelte @@ -0,0 +1,19 @@ + + + + + {#if underlined} + {displayCount} + {:else} + {displayCount} + {/if} + diff --git a/ts/routes/reviewer/reviewer-bottom/ReviewerBottom.svelte b/ts/routes/reviewer/reviewer-bottom/ReviewerBottom.svelte new file mode 100644 index 000000000..39160026e --- /dev/null +++ b/ts/routes/reviewer/reviewer-bottom/ReviewerBottom.svelte @@ -0,0 +1,86 @@ + + + +
+
+ +
+ +
+ {#if $answerShown} + {#each $answerButtons as answerButton} + + {/each} + {:else} + + + {/if} + +
+ +
+
+
+ + diff --git a/ts/routes/reviewer/reviewer-bottom/index.scss b/ts/routes/reviewer/reviewer-bottom/index.scss new file mode 100644 index 000000000..88bb1bae5 --- /dev/null +++ b/ts/routes/reviewer/reviewer-bottom/index.scss @@ -0,0 +1,15 @@ +/* Copyright: Ankitects Pty Ltd and contributors + * License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */ + +@use "../../../../qt/aqt/data/web/css/reviewer-bottom.scss"; +@use "../../../../qt/aqt/data/web/css/toolbar.scss"; +@use "../../../lib/sass/buttons"; + +html body { + font-size: 12px; + height: 72px; + + button { + min-width: 80px; + } +} \ No newline at end of file diff --git a/ts/routes/reviewer/reviewer-bottom/types.ts b/ts/routes/reviewer/reviewer-bottom/types.ts new file mode 100644 index 000000000..4068ca1f9 --- /dev/null +++ b/ts/routes/reviewer/reviewer-bottom/types.ts @@ -0,0 +1,9 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +export interface AnswerButtonInfo { + "extra": string; + "key": string; + "i": number; + "label": string; + "due": string; +} diff --git a/ts/routes/reviewer/reviewer.ts b/ts/routes/reviewer/reviewer.ts new file mode 100644 index 000000000..7aa411d31 --- /dev/null +++ b/ts/routes/reviewer/reviewer.ts @@ -0,0 +1,187 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +import { CardAnswer, type NextCardDataResponse_NextCardData } from "@generated/anki/scheduler_pb"; +import { compareAnswer, nextCardData, playAvtags } from "@generated/backend"; +import { derived, get, writable } from "svelte/store"; +import type { InnerReviewerRequest } from "../reviewer-inner/innerReviewerRequest"; +import type { ReviewerRequest } from "./reviewerRequest"; + +export function isNightMode() { + // https://stackoverflow.com/a/57795518 + // This will be true in browsers if darkmode but also false in the reviewer if darkmode + // If in the reviewer then this will need to be set by the python instead + return (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) + || document.documentElement.classList.contains("night-mode"); +} + +export function enableNightMode() { + document.documentElement.classList.add("night-mode"); + document.documentElement.setAttribute("data-bs-theme", "dark"); +} + +export function updateNightMode() { + if (isNightMode()) { + enableNightMode(); + } +} + +const typedAnswerRegex = /\[\[type:(.+?:)?(.+?)\]\]/m; + +export class ReviewerState { + answerHtml = ""; + currentTypedAnswer = ""; + _cardData: NextCardDataResponse_NextCardData | undefined = undefined; + beginAnsweringMs = Date.now(); + readonly cardClass = writable(""); + readonly answerShown = writable(false); + readonly cardData = writable(undefined); + readonly answerButtons = derived(this.cardData, ($cardData) => $cardData?.answerButtons ?? []); + + iframe: HTMLIFrameElement | undefined = undefined; + + onReady() { + this.iframe!.style.visibility = "visible"; + this.showQuestion(null); + addEventListener("message", this.onMessage.bind(this)); + } + + async onMessage(e: MessageEvent) { + switch (e.data.type) { + case "audio": { + const tags = get(this.answerShown) ? this._cardData!.answerAvTags : this._cardData!.questionAvTags; + playAvtags({ tags: [tags[e.data.index]] }); + break; + } + case "typed": { + this.currentTypedAnswer = e.data.value; + break; + } + case "keypress": { + this.handleKeyPress(e.data.key); + break; + } + } + } + + public registerIFrame(iframe: HTMLIFrameElement) { + this.iframe = iframe; + iframe.addEventListener("load", this.onReady.bind(this)); + } + + handleKeyPress(key: string) { + switch (key) { + case "1": { + this.easeButtonPressed(0); + break; + } + case "2": { + this.easeButtonPressed(1); + break; + } + case "3": { + this.easeButtonPressed(2); + break; + } + case "4": { + this.easeButtonPressed(3); + break; + } + case " ": { + if (!get(this.answerShown)) { + this.showAnswer(); + } else { + this.easeButtonPressed(2); + } + break; + } + } + } + + onKeyDown(e: KeyboardEvent) { + this.handleKeyPress(e.key); + } + + public registerShortcuts() { + document.addEventListener("keydown", this.onKeyDown.bind(this)); + } + + sendInnerRequest(message: InnerReviewerRequest) { + this.iframe?.contentWindow?.postMessage(message, "*"); + } + + updateHtml(htmlString: string, css?: string, bodyclass?: string) { + this.sendInnerRequest({ type: "html", value: htmlString, css, bodyclass }); + } + + async showQuestion(answer: CardAnswer | null) { + const resp = await nextCardData({ + answer: answer || undefined, + }); + + // TODO: "Congratulation screen" logic + this._cardData = resp.nextCard; + this.cardData.set(this._cardData); + this.answerShown.set(false); + + const question = resp.nextCard?.front || ""; + this.updateHtml(question, resp?.nextCard?.css, resp?.nextCard?.bodyClass); + if (this._cardData?.autoplay) { + playAvtags({ tags: this._cardData!.questionAvTags }); + } + + this.beginAnsweringMs = Date.now(); + } + + get currentCard() { + return this._cardData?.queue?.cards[0]; + } + + async showTypedAnswer(html: string) { + if (!this._cardData?.typedAnswer || !this._cardData.typedAnswerArgs) { + return html; + } + const compareAnswerResp = await compareAnswer({ + expected: this._cardData?.typedAnswer, + provided: this.currentTypedAnswer, + combining: !this._cardData.typedAnswerArgs.includes("nc"), + }); + const display = compareAnswerResp.val; + + console.log({ typedAnswerRegex, html, display }); + return html.replace(typedAnswerRegex, display); + } + + public async showAnswer() { + this.answerShown.set(true); + if (this._cardData?.autoplay) { + playAvtags({ tags: this._cardData!.answerAvTags }); + } + this.updateHtml(await this.showTypedAnswer(this._cardData?.back || "")); + } + + public easeButtonPressed(rating: number) { + if (!get(this.answerShown)) { + return; + } + + const states = this.currentCard!.states!; + + const newState = [ + states.again!, + states.hard!, + states.good!, + states.easy!, + ][rating]!; + + this.showQuestion( + new CardAnswer({ + rating: rating, + currentState: states!.current!, + newState, + cardId: this.currentCard?.card?.id, + answeredAtMillis: BigInt(Date.now()), + millisecondsTaken: Date.now() - this.beginAnsweringMs, + }), + ); + } +} diff --git a/ts/routes/reviewer/reviewerRequest.ts b/ts/routes/reviewer/reviewerRequest.ts new file mode 100644 index 000000000..bcd0f7fde --- /dev/null +++ b/ts/routes/reviewer/reviewerRequest.ts @@ -0,0 +1,19 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +interface AudioMessage { + type: "audio"; + answerSide: boolean; + index: number; +} + +interface UpdateTypedAnswerMessage { + type: "typed"; + value: string; +} + +interface KeyPressMessage { + type: "keypress"; + key: string; +} + +export type ReviewerRequest = AudioMessage | UpdateTypedAnswerMessage | KeyPressMessage;