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..bac47c8d2 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 34b350642..bd06ecd02 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,66 @@ message CardAnswer { uint32 milliseconds_taken = 6; } +message NextCardDataRequest { + optional CardAnswer answer = 1; +} + +message NextCardDataResponse { + message AnswerButton { + CardAnswer.Rating rating = 1; + string due = 2; + } + + 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; + repeated AnswerButton answer_buttons = 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/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..c7ebef28b 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -28,16 +28,33 @@ import aqt import aqt.main import aqt.operations from anki import hooks -from anki.collection import OpChanges, OpChangesOnly, Progress, SearchNode +from anki.cards import Card, CardId +from anki.collection import ( + OpChanges, + OpChangesOnly, + Progress, + SearchNode, +) from anki.decks import UpdateDeckConfigs +from anki.frontend_pb2 import PlayAVTagsRequest, ReviewerActionRequest from anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest +from anki.scheduler_pb2 import NextCardDataRequest, NextCardDataResponse +from anki.template import ( + PartiallyRenderedCard, + TemplateRenderContext, + apply_custom_filters, + av_tags_to_native, +) from anki.utils import dev_mode +from aqt import gui_hooks from aqt.changenotetype import ChangeNotetypeDialog from aqt.deckoptions import DeckOptionsDialog 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 av_player +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 @@ -45,7 +62,16 @@ waitress.wasyncore._DISCONNECTED = waitress.wasyncore._DISCONNECTED.union({EPROT logger = logging.getLogger(__name__) app = flask.Flask(__name__, root_path="/fake") -flask_cors.CORS(app, resources={r"/*": {"origins": "127.0.0.1"}}) +flask_cors.CORS( + app, + resources={ + r"/_anki/js/vendor/mathjax/output/chtml/fonts/woff-v2/.*.woff": { + "origins": "*" + }, + r"/media/.*": {"origins": "*"}, + r"/*": {"origins": "127.0.0.1"}, + }, +) @dataclass @@ -363,6 +389,7 @@ def is_sveltekit_page(path: str) -> bool: "import-csv", "import-page", "image-occlusion", + "reviewer", ] @@ -461,6 +488,7 @@ def _extract_request( if not aqt.mw.col: return NotFound(message=f"collection not open, ignore request for {path}") + path = path.removeprefix("media/") path = hooks.media_file_filter(path) return LocalFileRequest(root=aqt.mw.col.media.dir(), path=path) @@ -637,6 +665,136 @@ 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) + + av_player.stop_and_clear_queue() + aqt.mw.update_undo_actions() + + if len(data.next_card.queue.cards) == 0: + card = None + else: + backend_card = data.next_card.queue.cards[0].card + card = Card(aqt.mw.col, backend_card=backend_card) + + # TODO: Is dealing with gui_hooks in mediasrv like this a good idea? + if gui_hooks.reviewer_did_answer_card.count() > 0: + req = NextCardDataRequest.FromString(request.data) + if req.HasField("answer"): + aqt.mw.taskman.run_on_main( + lambda: gui_hooks.reviewer_did_answer_card( + aqt.mw.reviewer, + aqt.mw.col.get_card(CardId(req.answer.card_id)), + req.answer.rating + 1, # type: ignore + ) + ) + + reviewer = aqt.mw.reviewer + # This if statement prevents refreshes from causing the previous card to update. + if reviewer.card is None or card is None or card.id != reviewer.card.id: + reviewer.previous_card = reviewer.card + reviewer.card = card + + def update_card_info(): + reviewer._previous_card_info.set_card(reviewer.previous_card) + reviewer._card_info.set_card(card) + + aqt.mw.taskman.run_on_main(update_card_info) + + if card is None: + return data.SerializeToString() + + ctx = TemplateRenderContext.from_existing_card(card, False) + + qside = apply_custom_filters( + PartiallyRenderedCard.nodes_from_proto(data.next_card.partialTemplate.front), + ctx, + None, + ) + aside = apply_custom_filters( + PartiallyRenderedCard.nodes_from_proto(data.next_card.partialTemplate.back), + ctx, + qside, + ) + + # Dont send the partialy rendered template to the frontend to save bandwidth + data.next_card.ClearField("partialTemplate") + + 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) + data.next_card.accept_enter = aqt.mw.pm.spacebar_rates_card() + + return data.SerializeToString() + + +def play_avtags(): + req = PlayAVTagsRequest.FromString(request.data) + av_player.play_tags(av_tags_to_native(req.tags)) + return b"" + + +def reviewer_action(): + reviewer = aqt.mw.reviewer + ACTION_ENUM = ReviewerActionRequest.ReviewerAction + + def overview(): + aqt.mw.moveToState("overview") + + REVIEWER_ACTIONS = { + ACTION_ENUM.EditCurrent: aqt.mw.onEditCurrent, + ACTION_ENUM.SetDueDate: reviewer.on_set_due, + ACTION_ENUM.CardInfo: reviewer.on_card_info, + ACTION_ENUM.PreviousCardInfo: reviewer.on_previous_card_info, + ACTION_ENUM.CreateCopy: reviewer.on_create_copy, + ACTION_ENUM.Forget: reviewer.forget_current_card, + ACTION_ENUM.Options: reviewer.onOptions, + ACTION_ENUM.Overview: overview, + ACTION_ENUM.PauseAudio: reviewer.on_pause_audio, + ACTION_ENUM.SeekBackward: reviewer.on_seek_backward, + ACTION_ENUM.SeekForward: reviewer.on_seek_forward, + ACTION_ENUM.RecordVoice: reviewer.onRecordVoice, + ACTION_ENUM.ReplayRecorded: reviewer.onReplayRecorded, + } + + req = ReviewerActionRequest.FromString(request.data) + aqt.mw.taskman.run_on_main(REVIEWER_ACTIONS[req.menu]) + return b"" + + +def undo_redo(action: str): + resp = raw_backend_request(action)() + aqt.mw.update_undo_actions() + return resp + + +def undo(): + return undo_redo("undo") + + +def redo(): + return undo_redo("redo") + + post_handler_list = [ congrats_info, get_deck_configs_for_update, @@ -653,6 +811,11 @@ post_handler_list = [ deck_options_require_close, deck_options_ready, save_custom_colours, + next_card_data, + play_avtags, + reviewer_action, + undo, + redo, ] @@ -660,6 +823,9 @@ exposed_backend_list = [ # CollectionService "latest_progress", "get_custom_colours", + "set_config_json", + "get_config_json", + "get_undo_status", # DeckService "get_deck_names", # I18nService @@ -670,6 +836,9 @@ exposed_backend_list = [ # NotesService "get_field_names", "get_note", + "remove_notes", + "add_note_tags", + "remove_note_tags", # NotetypesService "get_notetype_names", "get_change_notetype_info", @@ -695,9 +864,13 @@ exposed_backend_list = [ "get_optimal_retention_parameters", "simulate_fsrs_review", "simulate_fsrs_workload", + "bury_or_suspend_cards", # 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..b20763877 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,7 @@ 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() 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..974ce244c 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -172,9 +172,7 @@ class Reviewer: gui_hooks.av_player_did_end_playing.append(self._on_av_player_did_end_playing) def show(self) -> None: - if self.mw.col.sched_ver() == 1 or not self.mw.col.v3_scheduler(): - self.mw.moveToState("deckBrowser") - show_warning(tr.scheduling_update_required().replace("V2", "v3")) + if not self._scheduler_version_check(): return self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore self.web.set_bridge_command(self._linkHandler, self) @@ -184,6 +182,13 @@ class Reviewer: self._refresh_needed = RefreshNeeded.QUEUES self.refresh_if_needed() + def _scheduler_version_check(self): + if self.mw.col.sched_ver() == 1 or not self.mw.col.v3_scheduler(): + self.mw.moveToState("deckBrowser") + show_warning(tr.scheduling_update_required().replace("V2", "v3")) + return False + return True + # this is only used by add-ons def lastCard(self) -> Card | None: if self._answeredIds: @@ -444,7 +449,6 @@ class Reviewer: tooltip(tr.studying_question_time_elapsed()) def autoplay(self, card: Card) -> bool: - print("use card.autoplay() instead of reviewer.autoplay(card)") return card.autoplay() def _update_flag_icon(self) -> None: @@ -1233,6 +1237,36 @@ timerStopped = false; setFlag = set_flag_on_current_card +class SvelteReviewer(Reviewer): + 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: + if not self._scheduler_version_check(): + return + self._initWeb() + # Prevents the shortcuts selecting the toolbar buttons for the next time enter is pressed + self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore + + 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 + + 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/toolbar.py b/qt/aqt/toolbar.py index be547b5ba..4517453ac 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 @@ -426,6 +430,7 @@ class Toolbar: ###################################################################### def _linkHandler(self, link: str) -> bool: + self.mw.web.setFocus() if link in self.link_handlers: self.link_handlers[link]() return False @@ -457,7 +462,7 @@ class Toolbar: ###################################################################### _body = """ -
+
{left_tray_content}
{toolbar_content}
{right_tray_content}
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..88bc34601 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 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..ce3021f1e 100644 --- a/rslib/src/scheduler/service/mod.rs +++ b/rslib/src/scheduler/service/mod.rs @@ -4,9 +4,16 @@ 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::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 +21,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 +30,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 +396,145 @@ 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); + + let answer_buttons = self + .describe_next_states(&next_card.states)? + .into_iter() + .enumerate() + .map(|(i, due)| AnswerButton { + rating: i as i32, + due: if show_due { + due + } else { + "\u{00A0}".to_string() /*   */ + }, + }) + .collect(); + + // 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), + }), + + answer_buttons, + 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 { diff --git a/ts/lib/tslib/shortcuts.ts b/ts/lib/tslib/shortcuts.ts index 0cb0b2f2d..7cfa2b680 100644 --- a/ts/lib/tslib/shortcuts.ts +++ b/ts/lib/tslib/shortcuts.ts @@ -6,36 +6,6 @@ import type { Modifier } from "./keys"; import { checkIfModifierKey, checkModifiers, keyToPlatformString, modifiersToPlatformString } from "./keys"; import { registerPackage } from "./runtime-require"; -const keyCodeLookup = { - Backspace: 8, - Delete: 46, - Tab: 9, - Enter: 13, - F1: 112, - F2: 113, - F3: 114, - F4: 115, - F5: 116, - F6: 117, - F7: 118, - F8: 119, - F9: 120, - F10: 121, - F11: 122, - F12: 123, - "=": 187, - "-": 189, - "[": 219, - "]": 221, - "\\": 220, - ";": 186, - "'": 222, - ",": 188, - ".": 190, - "/": 191, - "`": 192, -}; - function isRequiredModifier(modifier: string): boolean { return !modifier.endsWith("?"); } @@ -58,10 +28,8 @@ export function getPlatformString(keyCombinationString: string): string { .join(", "); } -function checkKey(event: KeyboardEvent, key: number): boolean { - // avoid deprecation warning - const which = event["which" + ""]; - return which === key; +function checkKey(event: KeyboardEvent, key: string): boolean { + return event.key.toLowerCase() === key.toLowerCase(); } function partition(predicate: (t: T) => boolean, items: T[]): [T[], T[]] { @@ -93,25 +61,23 @@ function separateRequiredOptionalModifiers( } const check = - (keyCode: number, requiredModifiers: Modifier[], optionalModifiers: Modifier[]) => - (event: KeyboardEvent): boolean => { + (key: string, requiredModifiers: Modifier[], optionalModifiers: Modifier[]) => (event: KeyboardEvent): boolean => { return ( - checkKey(event, keyCode) + checkKey(event, key) && checkModifiers(requiredModifiers, optionalModifiers)(event) ); }; -function keyToCode(key: string): number { - return keyCodeLookup[key] || key.toUpperCase().charCodeAt(0); -} - function keyCombinationToCheck( keyCombination: string[], ): (event: KeyboardEvent) => boolean { - const keyCode = keyToCode(keyCombination[keyCombination.length - 1]); + const keyCode = keyCombination[keyCombination.length - 1]; const [required, optional] = separateRequiredOptionalModifiers( keyCombination.slice(0, -1), ); + if ("@*!=".includes(keyCode)) { + optional.push("Shift"); + } return check(keyCode, required, optional); } diff --git a/ts/reviewer/index.ts b/ts/reviewer/index.ts index d0370cbc9..122d365dd 100644 --- a/ts/reviewer/index.ts +++ b/ts/reviewer/index.ts @@ -36,7 +36,7 @@ export function getTypedAnswer(): string | null { return typeans?.value ?? null; } -function _runHook( +export function _runHook( hooks: Array, ): Promise>[]> { const promises: (Promise | void)[] = []; @@ -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..4b777c4f1 --- /dev/null +++ b/ts/routes/reviewer-inner/index.ts @@ -0,0 +1,236 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +"use-strict"; +import "../base.scss"; +import "../../reviewer/reviewer.scss"; + +import "../../mathjax"; +import "mathjax/es5/tex-chtml-full.js"; +import { registerPackage } from "@tslib/runtime-require"; +import { _runHook, renderError } from "../../reviewer"; +import { addBrowserClasses } from "../../reviewer/browser_selector"; +import { preloadResources } from "../../reviewer/preload"; +import { imageOcclusionAPI } from "../image-occlusion/review"; +import { enableNightMode } from "../reviewer/reviewer"; +import type { ReviewerRequest } from "../reviewer/reviewerRequest"; +import type { InnerReviewerRequest } from "./innerReviewerRequest"; + +addBrowserClasses(); + +export const imageOcclusion = imageOcclusionAPI; +export const setupImageCloze = imageOcclusionAPI.setup; // deprecated + +function postParentMessage(message: ReviewerRequest) { + window.parent.postMessage( + message, + "*", + ); +} + +type Callback = () => void | Promise; + +export const onUpdateHook: Array = []; +export const onShownHook: Array = []; + +globalThis.onUpdateHook = onUpdateHook; +globalThis.onShownHook = onShownHook; + +declare const MathJax: any; +const urlParams = new URLSearchParams(location.search); +const decoder = new TextDecoder(); +const style = document.createElement("style"); +document.head.appendChild(style); +const qaDiv = document.createElement("div"); +document.body.appendChild(qaDiv); +qaDiv.id = "qa"; +qaDiv.style.opacity = "1"; + +addEventListener("message", async (e: MessageEvent) => { + switch (e.data.type) { + case "setstorage": { + const json = JSON.parse(decoder.decode(e.data.json_buffer)); + Object.assign(storageObj, json); + break; + } + case "html": { + qaDiv.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"); + } + } + + onUpdateHook.length = 0; + onShownHook.length = 0; + + // "".innerHTML =" does not run scripts + for (const script of qaDiv.querySelectorAll("script")) { + // strict mode prevents the use of "eval" here + const parent = script.parentElement!; + const _script = script.parentElement!.removeChild(script); + const new_script = document.createElement("script"); + const new_script_text = document.createTextNode(_script.innerHTML); + new_script.appendChild(new_script_text); + parent.appendChild(new_script); + } + + _runHook(onUpdateHook); + // 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")); + + const scrollTarget = document.getElementById("answer"); + if (scrollTarget) { + scrollTarget.scrollIntoView(); + } else { + window.scrollTo(0, 0); + } + + _runHook(onShownHook); + + if (e.data.preload) { + preloadResources(e.data.preload); + } + break; + } + default: { + // console.warn(`Unknown message type: ${e.data.type}`); + break; + } + } +}); + +addEventListener("keydown", (e) => { + const keyInfo: ReviewerRequest = { + type: "keypress", + eventInit: { + key: e.key, + code: e.code, + keyCode: e.keyCode, + which: e.which, + altKey: e.altKey, + ctrlKey: e.ctrlKey, + shiftKey: e.shiftKey, + metaKey: e.metaKey, + repeat: e.repeat, + bubbles: true, + }, + }; + if ( + !document.activeElement?.matches("input[type=text], input[type=number], textarea") || e.key === "Enter" + ) { + postParentMessage(keyInfo); + } +}); + +addEventListener("click", () => { + postParentMessage({ type: "closemenu" }); +}); + +const base = document.createElement("base"); +base.href = "/media/"; +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; + +const storageObj = {}; +const encoder = new TextEncoder(); + +function updateParentStorage() { + postParentMessage({ type: "setstorage", json_buffer: encoder.encode(JSON.stringify(storageObj)) }); +} + +function createStorageProxy() { + return new Proxy({}, { + get(_target, prop) { + switch (prop) { + case "getItem": + return (key) => key in storageObj ? storageObj[key] : null; + case "setItem": + return (key, value) => { + storageObj[key] = String(value); + updateParentStorage(); + }; + case "removeItem": + return (key) => { + delete storageObj[key]; + updateParentStorage(); + }; + case "clear": + return () => { + Object.keys(storageObj).forEach(key => delete storageObj[key]); + updateParentStorage(); + }; + case "key": + return (index) => Object.keys(storageObj)[index] ?? null; + case "length": + return Object.keys(storageObj).length; + default: + return storageObj[prop]; + } + }, + set(_target, prop, value) { + storageObj[prop] = String(value); + return true; + }, + ownKeys() { + return Object.keys(storageObj); + }, + getOwnPropertyDescriptor(_target, _prop) { + return { enumerable: true, configurable: true }; + }, + }); +} + +const ankiStorage = createStorageProxy(); + +Object.defineProperty(window, "localStorage", { + value: ankiStorage, + writable: false, + configurable: true, + enumerable: true, +}); + +registerPackage("anki/reviewer", { + // If you append a function to this each time the question or answer + // is shown, it will be called before MathJax has been rendered. + onUpdateHook, + // If you append a function to this each time the question or answer + // is shown, it will be called after images have been preloaded and + // MathJax has been rendered. + onShownHook, +}); diff --git a/ts/routes/reviewer-inner/innerReviewerRequest.ts b/ts/routes/reviewer-inner/innerReviewerRequest.ts new file mode 100644 index 000000000..d7aab68d4 --- /dev/null +++ b/ts/routes/reviewer-inner/innerReviewerRequest.ts @@ -0,0 +1,16 @@ +// 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; + preload?: string; +} + +interface StorageUpdateMessage { + type: "setstorage"; + json_buffer: Uint8Array; +} + +export type InnerReviewerRequest = HtmlMessage | StorageUpdateMessage; diff --git a/ts/routes/reviewer/+page.svelte b/ts/routes/reviewer/+page.svelte new file mode 100644 index 000000000..31ab196dc --- /dev/null +++ b/ts/routes/reviewer/+page.svelte @@ -0,0 +1,38 @@ + + + +
+ + +
+ + diff --git a/ts/routes/reviewer/+page.ts b/ts/routes/reviewer/+page.ts new file mode 100644 index 000000000..fc5d7d672 --- /dev/null +++ b/ts/routes/reviewer/+page.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 +import { getUndoStatus } from "@generated/backend"; +import type { PageLoad } from "./$types"; + +export const load = (async () => { + const initialUndoStatus = await getUndoStatus({}); + return { initialUndoStatus }; +}) satisfies PageLoad; diff --git a/ts/routes/reviewer/Reviewer.svelte b/ts/routes/reviewer/Reviewer.svelte new file mode 100644 index 000000000..8dd73d720 --- /dev/null +++ b/ts/routes/reviewer/Reviewer.svelte @@ -0,0 +1,66 @@ + + + +
+ + +
+ {$tooltipMessage} +
+
+ +{#if $flag} +
+{/if} + +{#if $marked} +
+{/if} + + diff --git a/ts/routes/reviewer/index.scss b/ts/routes/reviewer/index.scss new file mode 100644 index 000000000..2c6dfa6e9 --- /dev/null +++ b/ts/routes/reviewer/index.scss @@ -0,0 +1 @@ +@use "../../reviewer/reviewer.scss"; \ No newline at end of file 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..7888b0d03 --- /dev/null +++ b/ts/routes/reviewer/reviewer-bottom/More.svelte @@ -0,0 +1,269 @@ + + + + (showFloating = !showFloating)} +/> + +{#each shortcuts as shortcut} + {#if shortcut !== "hr"} + + {/if} +{/each} + +{#each flags as flag, i} + { + state.changeFlag(i + 1); + }} + /> +{/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..5b267a8b3 --- /dev/null +++ b/ts/routes/reviewer/reviewer-bottom/MoreItem.svelte @@ -0,0 +1,46 @@ + + + +
+ + {shortcut} + {#if submenu} + {"▸"} + {/if} +
+ + diff --git a/ts/routes/reviewer/reviewer-bottom/MoreSubmenu.svelte b/ts/routes/reviewer/reviewer-bottom/MoreSubmenu.svelte new file mode 100644 index 000000000..a1b342cfa --- /dev/null +++ b/ts/routes/reviewer/reviewer-bottom/MoreSubmenu.svelte @@ -0,0 +1,42 @@ + + + +
+ + + + + + + +
+ + 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..97a04347f --- /dev/null +++ b/ts/routes/reviewer/reviewer-bottom/ReviewerBottom.svelte @@ -0,0 +1,101 @@ + + + +
+
+ +
+ +
+ {#if $answerShown} + {#each $answerButtons as answerButton} + + {/each} + {:else} + {#if remainingShown} + + {:else} +   + {/if} + + {/if} +
+ {#if $cardData?.timer} + + {/if} +
+
+ +
+
+
+ + diff --git a/ts/routes/reviewer/reviewer-bottom/Timer.svelte b/ts/routes/reviewer/reviewer-bottom/Timer.svelte new file mode 100644 index 000000000..3e5c07470 --- /dev/null +++ b/ts/routes/reviewer/reviewer-bottom/Timer.svelte @@ -0,0 +1,67 @@ + + + +
+ {text} +
+ + 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..a50c617a3 --- /dev/null +++ b/ts/routes/reviewer/reviewer-bottom/types.ts @@ -0,0 +1,15 @@ +// 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; +} + +export type MoreMenuItemInfo = { + name: string; + onClick: () => any; + shortcut: string; +} | "hr"; diff --git a/ts/routes/reviewer/reviewer.ts b/ts/routes/reviewer/reviewer.ts new file mode 100644 index 000000000..ba9d13f5f --- /dev/null +++ b/ts/routes/reviewer/reviewer.ts @@ -0,0 +1,507 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +import type { AVTag } from "@generated/anki/card_rendering_pb"; +import type { UndoStatus } from "@generated/anki/collection_pb"; +import { DeckConfig_Config_AnswerAction, DeckConfig_Config_QuestionAction } from "@generated/anki/deck_config_pb"; +import { ReviewerActionRequest_ReviewerAction } from "@generated/anki/frontend_pb"; +import { + BuryOrSuspendCardsRequest_Mode, + CardAnswer, + type NextCardDataResponse_NextCardData, +} from "@generated/anki/scheduler_pb"; +import { + addNoteTags, + buryOrSuspendCards, + compareAnswer, + getConfigJson, + nextCardData, + playAvtags, + redo, + removeNotes, + removeNoteTags, + reviewerAction, + setConfigJson, + setFlag, + undo, +} from "@generated/backend"; +import * as tr from "@generated/ftl"; +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; +const TOOLTIP_TIMEOUT_MS = 2000; + +export class ReviewerState { + answerHtml = ""; + currentTypedAnswer = ""; + _cardData: NextCardDataResponse_NextCardData | undefined = undefined; + beginAnsweringMs = Date.now(); + answerMs: number | undefined = undefined; + readonly cardClass = writable(""); + readonly answerShown = writable(false); + readonly cardData = writable(undefined); + readonly answerButtons = derived(this.cardData, ($cardData) => $cardData?.answerButtons ?? []); + tooltipMessageTimeout: ReturnType | undefined; + readonly tooltipMessage = writable(""); + readonly tooltipShown = writable(false); + readonly flag = writable(0); + readonly marked = writable(false); + readonly autoAdvance = writable(false); + undoStatus: UndoStatus | undefined = undefined; + autoAdvanceQuestionTimeout: ReturnType | undefined; + autoAdvanceAnswerTimeout: ReturnType | undefined; + _answerShown = false; + + iframe: HTMLIFrameElement | undefined = undefined; + + constructor() { + this.autoAdvance.subscribe($autoAdvance => { + if (this._answerShown) { + this.updateAutoAdvanceAnswer(); + } else { + this.updateAutoAdvanceQuestion(); + } + if (!$autoAdvance) { + clearInterval(this.autoAdvanceQuestionTimeout); + clearInterval(this.autoAdvanceAnswerTimeout); + } + }); + } + + public toggleAutoAdvance() { + this.autoAdvance.update(($autoAdvance) => { + // Reversed because the $autoAdvance will be flipped by the return. + this.showTooltip($autoAdvance ? tr.actionsAutoAdvanceDeactivated() : tr.actionsAutoAdvanceActivated()); + return !$autoAdvance; + }); + } + + async onReady() { + const { json } = await getConfigJson({ val: "reviewerStorage" }); + this.sendInnerRequest({ type: "setstorage", json_buffer: json }); + 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; + this.playAudio([tags[e.data.index]]); + break; + } + case "typed": { + this.currentTypedAnswer = e.data.value; + break; + } + case "keypress": { + // This is a hacky fix because otherwise while focused on the reviewer-bottom, pressing m only keeps the menu open for the duration of the button press (using "keyup" in the shortcut in More.svelte fixed this) + const forceKeyUp = e.data.eventInit.key?.toLowerCase() == "m"; + document.dispatchEvent(new KeyboardEvent(forceKeyUp ? "keyup" : "keydown", e.data.eventInit)); + break; + } + case "closemenu": { + document.dispatchEvent(new CustomEvent("closemenu")); + break; + } + case "setstorage": { + setConfigJson({ + key: "reviewerStorage", + valueJson: e.data.json_buffer, + undoable: false, + }); + } + } + } + + public registerIFrame(iframe: HTMLIFrameElement) { + this.iframe = iframe; + iframe.addEventListener("load", this.onReady.bind(this)); + } + + public refresh() { + this.showQuestion(null); + } + + reviewerAction(menu: ReviewerActionRequest_ReviewerAction) { + reviewerAction({ menu, currentCardId: this.currentCard?.card?.id }); + } + + public displayEditMenu() { + this.reviewerAction(ReviewerActionRequest_ReviewerAction.EditCurrent); + } + + public displaySetDueDateMenu() { + this.reviewerAction(ReviewerActionRequest_ReviewerAction.SetDueDate); + } + + public displayCardInfoMenu() { + this.reviewerAction(ReviewerActionRequest_ReviewerAction.CardInfo); + } + + public displayPreviousCardInfoMenu() { + this.reviewerAction(ReviewerActionRequest_ReviewerAction.PreviousCardInfo); + } + + public displayCreateCopyMenu() { + this.reviewerAction(ReviewerActionRequest_ReviewerAction.CreateCopy); + } + + public displayForgetMenu() { + this.reviewerAction(ReviewerActionRequest_ReviewerAction.Forget); + } + + public displayOptionsMenu() { + this.reviewerAction(ReviewerActionRequest_ReviewerAction.Options); + } + + public displayOverview() { + this.reviewerAction(ReviewerActionRequest_ReviewerAction.Overview); + } + + public playAudio(tags: AVTag[]) { + if (tags.length) { + playAvtags({ tags }); + } + } + + maybeAutoPlayAudio(tags: AVTag[]) { + if (this._cardData?.autoplay) { + this.playAudio(tags); + } + } + + public replayAudio() { + if (this._answerShown) { + this.playAudio(this._cardData!.answerAvTags); + } else { + this.playAudio(this._cardData!.questionAvTags); + } + } + + public pauseAudio() { + this.reviewerAction(ReviewerActionRequest_ReviewerAction.PauseAudio); + } + + public AudioSeekBackward() { + this.reviewerAction(ReviewerActionRequest_ReviewerAction.SeekBackward); + } + + public AudioSeekForward() { + this.reviewerAction(ReviewerActionRequest_ReviewerAction.SeekForward); + } + + public RecordVoice() { + this.reviewerAction(ReviewerActionRequest_ReviewerAction.RecordVoice); + } + + public ReplayRecorded() { + this.reviewerAction(ReviewerActionRequest_ReviewerAction.ReplayRecorded); + } + + public async toggleMarked() { + if (this._cardData && this.currentCard?.card?.noteId) { + const noteIds = [this.currentCard.card.noteId]; + if (this._cardData.marked) { + await removeNoteTags({ noteIds, tags: "marked" }); + this.setUndo(tr.actionsRemoveTag()); + } else { + await addNoteTags({ noteIds, tags: "marked" }); + this.setUndo(tr.actionsUpdateTag()); + } + this.marked.update($marked => !$marked); + this._cardData.marked = !this._cardData.marked; + } + } + + public changeFlag(index: number) { + this.flag.update($flag => { + if ($flag === index) { + index = 0; + } + setFlag({ cardIds: [this.currentCard!.card!.id], flag: index }); + return index; + }); + } + + public showTooltip(message: string) { + clearTimeout(this.tooltipMessageTimeout); + this.tooltipMessage.set(message); + this.tooltipShown.set(true); + this.tooltipMessageTimeout = setTimeout(() => { + this.tooltipShown.set(false); + }, TOOLTIP_TIMEOUT_MS); + } + + public setUndo(status: string) { + // For a list of statuses, see + // https://github.com/ankitects/anki/blob/acdf486b290bd47d13e2e880fbb1c14773899091/rslib/src/ops.rs#L57 + if (this.undoStatus) { // Skip if "undoStatus" is disabled / not set + this.undoStatus.undo = status; + } + } + + public setBuryOrSuspendUndo(suspend: boolean) { + this.setUndo(suspend ? tr.studyingSuspend() : tr.studyingBury()); + } + + public async buryOrSuspendCurrentCard(suspend: boolean) { + const mode = suspend ? BuryOrSuspendCardsRequest_Mode.SUSPEND : BuryOrSuspendCardsRequest_Mode.BURY_USER; + if (this.currentCard?.card?.id) { + await buryOrSuspendCards({ + cardIds: [this.currentCard.card.id], + noteIds: [], + mode, + }); + this.showTooltip(suspend ? tr.studyingCardSuspended() : tr.studyingCardsBuried({ count: 1 })); + this.setBuryOrSuspendUndo(suspend); + this.refresh(); + } + } + + public async buryOrSuspendCurrentNote(suspend: boolean) { + const mode = suspend ? BuryOrSuspendCardsRequest_Mode.SUSPEND : BuryOrSuspendCardsRequest_Mode.BURY_USER; + if (this.currentCard?.card?.noteId) { + const op = await buryOrSuspendCards({ + cardIds: [], + noteIds: [this.currentCard.card.noteId], + mode, + }); + this.showTooltip(suspend ? tr.studyingNoteSuspended() : tr.studyingCardsBuried({ count: op.count })); + this.setBuryOrSuspendUndo(suspend); + this.refresh(); + } + } + + public async deleteCurrentNote() { + if (this.currentCard?.card?.noteId) { + const op = await removeNotes({ noteIds: [this.currentCard.card.noteId], cardIds: [] }); + this.showTooltip(tr.browsingCardsDeleted({ count: op.count })); + this.setUndo(tr.studyingDeleteNote()); + this.refresh(); + } + } + + async handleKeyPress(key: string, ctrl: boolean, shift: boolean) { + key = key.toLowerCase(); + 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 " ": + case "enter": { + if (!this._answerShown) { + this.showAnswer(); + } else if (this._cardData?.acceptEnter ?? true) { + this.easeButtonPressed(2); + } + break; + } + case "z": { + if (ctrl) { + if (shift && this.undoStatus?.redo) { + const op = await redo({}); + this.showTooltip(tr.undoActionRedone({ action: op.operation })); + this.undoStatus = op.newStatus; + } else if (this.undoStatus?.undo) { + const op = await undo({}); + this.showTooltip(tr.undoActionUndone({ action: op.operation })); + this.undoStatus = op.newStatus; + } else { + this.showTooltip(shift ? tr.actionsNothingToRedo() : tr.actionsNothingToUndo()); + } + this.refresh(); + } + break; + } + case "e": { + if (!ctrl) { + this.displayEditMenu(); + break; + } + } + } + } + + onKeyDown(e: KeyboardEvent) { + if (e.repeat) { + return; + } + if (e.key == "Enter") { + e.preventDefault(); + } + this.handleKeyPress(e.key, e.ctrlKey, e.shiftKey); + } + + public registerShortcuts() { + window.addEventListener("keydown", this.onKeyDown.bind(this)); + } + + sendInnerRequest(message: InnerReviewerRequest) { + this.iframe?.contentWindow?.postMessage(message, "*"); + } + + updateHtml(htmlString: string, css?: string, bodyclass?: string, preload?: string) { + this.sendInnerRequest({ type: "html", value: htmlString, css, bodyclass, preload }); + } + + updateAutoAdvanceQuestion() { + clearTimeout(this.autoAdvanceAnswerTimeout); + if (get(this.autoAdvance) && this._cardData!.autoAdvanceQuestionSeconds) { + const action = ({ + [DeckConfig_Config_QuestionAction.SHOW_ANSWER]: () => { + this.showAnswer(); + }, + [DeckConfig_Config_QuestionAction.SHOW_REMINDER]: () => { + this.showTooltip(tr.studyingQuestionTimeElapsed()); + }, + })[this._cardData!.autoAdvanceQuestionAction]; + + this.autoAdvanceQuestionTimeout = setTimeout(action, this._cardData!.autoAdvanceQuestionSeconds * 1000); + } + } + + updateAutoAdvanceAnswer() { + clearTimeout(this.autoAdvanceQuestionTimeout); + if (get(this.autoAdvance) && this._cardData?.autoAdvanceAnswerSeconds) { + const action = ({ + [DeckConfig_Config_AnswerAction.ANSWER_AGAIN]: () => { + this.easeButtonPressed(0); + }, + [DeckConfig_Config_AnswerAction.ANSWER_HARD]: () => { + this.easeButtonPressed(1); + }, + [DeckConfig_Config_AnswerAction.ANSWER_GOOD]: () => { + this.easeButtonPressed(2); + }, + [DeckConfig_Config_AnswerAction.BURY_CARD]: () => { + this.buryOrSuspendCurrentCard(false); + }, + [DeckConfig_Config_AnswerAction.SHOW_REMINDER]: () => { + this.showTooltip(tr.studyingAnswerTimeElapsed()); + }, + })[this._cardData.autoAdvanceAnswerAction]; + + this.autoAdvanceAnswerTimeout = setTimeout(action, this._cardData.autoAdvanceAnswerSeconds * 1000); + } + } + + async showQuestion(answer: CardAnswer | null) { + if (answer !== null) { + this.setUndo(tr.actionsAnswerCard()); + } + + this._answerShown = false; + const resp = await nextCardData({ + answer: answer || undefined, + }); + + if (!resp.nextCard) { + this.displayOverview(); + return; + } + + this._cardData = resp.nextCard; + this.cardData.set(this._cardData); + this.flag.set(this.currentCard?.card?.flags ?? 0); + this.marked.set(this._cardData.marked); + this.answerShown.set(false); + + const question = resp.nextCard?.front || ""; + this.updateHtml(question, resp?.nextCard?.css, resp?.nextCard?.bodyClass, resp?.preload); + this.iframe!.style.visibility = "visible"; + this.maybeAutoPlayAudio(this._cardData.questionAvTags); + this.beginAnsweringMs = Date.now(); + this.answerMs = undefined; + this.updateAutoAdvanceQuestion(); + } + + get currentCard() { + return this._cardData?.queue?.cards[0]; + } + + async showTypedAnswer(html: string) { + if (this._cardData?.typedAnswer === undefined) { + return html; + } + const args = this._cardData.typedAnswer.args; + const compareAnswerResp = await compareAnswer({ + expected: this._cardData.typedAnswer.text, + provided: this.currentTypedAnswer, + combining: !args.includes("nc"), + }); + + const prefix = args.includes("cloze") ? "
" : ""; + const display = prefix + compareAnswerResp.val; + + return html.replace(typedAnswerRegex, display); + } + + public async showAnswer() { + this.answerShown.set(true); + this._answerShown = true; + this.maybeAutoPlayAudio(this._cardData!.answerAvTags); + this.answerMs = Date.now(); + this.updateHtml(await this.showTypedAnswer(this._cardData?.back || "")); + this.updateAutoAdvanceAnswer(); + } + + public easeButtonPressed(rating: number) { + if (!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..e876f417f --- /dev/null +++ b/ts/routes/reviewer/reviewerRequest.ts @@ -0,0 +1,33 @@ +// 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"; + eventInit: KeyboardEventInit; +} + +interface CloseMenuMessage { + type: "closemenu"; +} + +interface SetStorageMessage { + type: "setstorage"; + json_buffer: Uint8Array; +} + +export type ReviewerRequest = + | AudioMessage + | UpdateTypedAnswerMessage + | KeyPressMessage + | SetStorageMessage + | CloseMenuMessage;