Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion qt/aqt/data/web/css/reviewer-bottom.scss
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,4 @@ button {
#outer {
border-top-color: color(border-subtle);
}
}
}
205 changes: 202 additions & 3 deletions qt/aqt/mediasrv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,24 +29,51 @@
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
waitress.wasyncore._DISCONNECTED = waitress.wasyncore._DISCONNECTED.union({EPROTOTYPE}) # type: ignore

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
Expand Down Expand Up @@ -363,6 +391,7 @@ def is_sveltekit_page(path: str) -> bool:
"import-csv",
"import-page",
"image-occlusion",
"reviewer",
]


Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand All @@ -653,13 +813,21 @@ def save_custom_colours() -> bytes:
deck_options_require_close,
deck_options_ready,
save_custom_colours,
next_card_data,
play_avtags,
reviewer_action,
undo,
redo,
]


exposed_backend_list = [
# CollectionService
"latest_progress",
"get_custom_colours",
"set_config_json",
"get_config_json",
"get_undo_status",
# DeckService
"get_deck_names",
# I18nService
Expand All @@ -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",
Expand All @@ -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",
]


Expand All @@ -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
Expand Down
43 changes: 39 additions & 4 deletions qt/aqt/reviewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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("<style>body {margin:0;} html {height:0;}</style>")
# 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
Expand Down
Loading