From c3d5a235f5d195abaf8ac1a69d8a0b9892bff5aa Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Tue, 12 May 2026 14:51:14 -0400 Subject: [PATCH] Split reviewer media server integration --- qt/aqt/data/web/css/reviewer-bottom.scss | 2 +- qt/aqt/mediasrv.py | 205 ++++++++++++++++++++++- qt/aqt/reviewer.py | 43 ++++- qt/aqt/toolbar.py | 9 +- 4 files changed, 249 insertions(+), 10 deletions(-) diff --git a/qt/aqt/data/web/css/reviewer-bottom.scss b/qt/aqt/data/web/css/reviewer-bottom.scss index 59098a5fbe5..8368f4da807 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/mediasrv.py b/qt/aqt/mediasrv.py index bedf23e5bc5..bfed85ca01f 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -16,6 +16,7 @@ from dataclasses import dataclass from errno import EPROTOTYPE from http import HTTPStatus +from typing import Any import flask import flask_cors @@ -28,16 +29,34 @@ 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 ( + NestedOpChanges, + 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 +64,16 @@ 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 +391,7 @@ def is_sveltekit_page(path: str) -> bool: "import-csv", "import-page", "image-occlusion", + "reviewer", ] @@ -461,6 +490,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 +667,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 +813,11 @@ def save_custom_colours() -> bytes: deck_options_require_close, deck_options_ready, save_custom_colours, + next_card_data, + play_avtags, + reviewer_action, + undo, + redo, ] @@ -660,6 +825,9 @@ def save_custom_colours() -> bytes: # CollectionService "latest_progress", "get_custom_colours", + "set_config_json", + "get_config_json", + "get_undo_status", # DeckService "get_deck_names", # I18nService @@ -670,6 +838,9 @@ def save_custom_colours() -> bytes: # NotesService "get_field_names", "get_note", + "remove_notes", + "add_note_tags", + "remove_note_tags", # NotetypesService "get_notetype_names", "get_change_notetype_info", @@ -695,9 +866,16 @@ def save_custom_colours() -> bytes: "get_optimal_retention_parameters", "simulate_fsrs_review", "simulate_fsrs_workload", + "bury_or_suspend_cards", + "describe_next_states", # DeckConfigService "get_ignored_before_count", "get_retention_workload", + # ConfigService + "get_config_string", + # CardsService + "set_flag", + "compare_answer", ] @@ -707,7 +885,28 @@ def raw_backend_request(endpoint: str) -> Callable[[], bytes]: assert hasattr(RustBackend, f"{endpoint}_raw") - return lambda: getattr(aqt.mw.col._backend, f"{endpoint}_raw")(request.data) + def wrapped() -> bytes: + output = getattr(aqt.mw.col._backend, f"{endpoint}_raw")(request.data) + if op_changes_type := int(request.headers.get("Anki-Op-Changes", "0")): + op_message_types = (OpChanges, OpChangesOnly, NestedOpChanges) + try: + response = op_message_types[op_changes_type - 1]() + response.ParseFromString(output) + changes: Any = response + for _ in range(op_changes_type - 1): + changes = changes.changes + except IndexError: + raise ValueError(f"unhandled op changes level: {op_changes_type}") + + def handle_on_main() -> None: + handler = aqt.mw.app.activeWindow() + on_op_finished(aqt.mw, changes, handler) + + aqt.mw.taskman.run_on_main(handle_on_main) + + return output + + return wrapped # all methods in here require a collection diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 6d68f9e3ac7..17e043daa4c 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -172,9 +172,7 @@ def __init__(self, mw: AnkiQt) -> None: 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 @@ def show(self) -> None: 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 @@ def _on_show_answer_timeout(self) -> None: 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,37 @@ def auto_advance_if_enabled(self) -> None: 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 + self.web.set_open_iframe_links_externally(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 be547b5ba55..4517453ac09 100644 --- a/qt/aqt/toolbar.py +++ b/qt/aqt/toolbar.py @@ -211,7 +211,11 @@ def on_body_classes_need_update(self) -> None: 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 @@ def update_sync_status(self) -> None: ###################################################################### 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 @@ def _syncLinkHandler(self) -> None: ###################################################################### _body = """ -
+
{left_tray_content}
{toolbar_content}
{right_tray_content}