This commit is contained in:
Luc Mcgrady 2025-12-29 23:19:32 +00:00 committed by GitHub
commit cdc8ce98b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 2095 additions and 65 deletions

View file

@ -228,6 +228,17 @@ fn build_and_check_pages(build: &mut Build) -> Result<()> {
":sveltekit" ":sveltekit"
], ],
)?; )?;
build_page(
"reviewer-inner",
true,
inputs![
//
":ts:lib",
":ts:components",
":sass",
":sveltekit"
],
)?;
Ok(()) Ok(())
} }

View file

@ -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-clipboard-images-as-png = Paste clipboard images as PNG
preferences-paste-without-shift-key-strips-formatting = Paste without shift key strips formatting preferences-paste-without-shift-key-strips-formatting = Paste without shift key strips formatting
preferences-generate-latex-images-automatically = Generate LaTeX images (security risk) 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-latex-generation-disabled = LaTeX image generation is disabled in the preferences.
preferences-periodically-sync-media = Periodically sync media preferences-periodically-sync-media = Periodically sync media
preferences-please-restart-anki-to-complete-language = Please restart Anki to complete language change. preferences-please-restart-anki-to-complete-language = Please restart Anki to complete language change.

View file

@ -57,6 +57,7 @@ message ConfigKey {
LOAD_BALANCER_ENABLED = 26; LOAD_BALANCER_ENABLED = 26;
FSRS_SHORT_TERM_WITH_STEPS_ENABLED = 27; FSRS_SHORT_TERM_WITH_STEPS_ENABLED = 27;
FSRS_LEGACY_EVALUATE = 28; FSRS_LEGACY_EVALUATE = 28;
NEW_REVIEWER = 29;
} }
enum String { enum String {
SET_DUE_BROWSER = 0; SET_DUE_BROWSER = 0;
@ -120,6 +121,7 @@ message Preferences {
uint32 time_limit_secs = 5; uint32 time_limit_secs = 5;
bool load_balancer_enabled = 6; bool load_balancer_enabled = 6;
bool fsrs_short_term_with_steps_enabled = 7; bool fsrs_short_term_with_steps_enabled = 7;
bool new_reviewer = 8;
} }
message Editing { message Editing {
bool adding_defaults_to_current_deck = 1; bool adding_defaults_to_current_deck = 1;

View file

@ -10,6 +10,7 @@ package anki.frontend;
import "anki/scheduler.proto"; import "anki/scheduler.proto";
import "anki/generic.proto"; import "anki/generic.proto";
import "anki/search.proto"; import "anki/search.proto";
import "anki/card_rendering.proto";
service FrontendService { service FrontendService {
// Returns values from the reviewer // Returns values from the reviewer
@ -30,6 +31,10 @@ service FrontendService {
// Save colour picker's custom colour palette // Save colour picker's custom colour palette
rpc SaveCustomColours(generic.Empty) returns (generic.Empty); 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 {} service BackendFrontendService {}
@ -43,3 +48,35 @@ message SetSchedulingStatesRequest {
string key = 1; string key = 1;
scheduler.SchedulingStates states = 2; 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;
}

View file

@ -13,10 +13,12 @@ import "anki/decks.proto";
import "anki/collection.proto"; import "anki/collection.proto";
import "anki/config.proto"; import "anki/config.proto";
import "anki/deck_config.proto"; import "anki/deck_config.proto";
import "anki/card_rendering.proto";
service SchedulerService { service SchedulerService {
rpc GetQueuedCards(GetQueuedCardsRequest) returns (QueuedCards); rpc GetQueuedCards(GetQueuedCardsRequest) returns (QueuedCards);
rpc AnswerCard(CardAnswer) returns (collection.OpChanges); rpc AnswerCard(CardAnswer) returns (collection.OpChanges);
rpc NextCardData(NextCardDataRequest) returns (NextCardDataResponse);
rpc SchedTimingToday(generic.Empty) returns (SchedTimingTodayResponse); rpc SchedTimingToday(generic.Empty) returns (SchedTimingTodayResponse);
rpc StudiedToday(generic.Empty) returns (generic.String); rpc StudiedToday(generic.Empty) returns (generic.String);
rpc StudiedTodayMessage(StudiedTodayMessageRequest) returns (generic.String); rpc StudiedTodayMessage(StudiedTodayMessageRequest) returns (generic.String);
@ -285,6 +287,66 @@ message CardAnswer {
uint32 milliseconds_taken = 6; 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 CustomStudyRequest {
message Cram { message Cram {
enum CramKind { enum CramKind {

View file

@ -451,6 +451,19 @@
</property> </property>
</widget> </widget>
</item> </item>
<item>
<widget class="QCheckBox" name="new_reviewer">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>preferences_use_new_reviewer</string>
</property>
</widget>
</item>
<item> <item>
<widget class="QPushButton" name="url_schemes"> <widget class="QPushButton" name="url_schemes">
<property name="text"> <property name="text">

View file

@ -255,13 +255,11 @@ class AnkiQt(QMainWindow):
# screens # screens
self.setupDeckBrowser() self.setupDeckBrowser()
self.setupOverview() self.setupOverview()
self.setupReviewer() # self.setupReviewer()
def finish_ui_setup(self) -> None: def finish_ui_setup(self) -> None:
"Actions that are deferred until after add-on loading." "Actions that are deferred until after add-on loading."
self.toolbar.draw() self.toolbar.draw()
# add-ons are only available here after setupAddons
gui_hooks.reviewer_did_init(self.reviewer)
def setupProfileAfterWebviewsLoaded(self) -> None: def setupProfileAfterWebviewsLoaded(self) -> None:
for w in (self.web, self.bottomWeb): 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 # dump error to stderr so it gets picked up by errors.py
traceback.print_exc() traceback.print_exc()
self.setupReviewer(self.backend.get_config_bool(Config.Bool.NEW_REVIEWER))
return True return True
def _loadCollection(self) -> None: def _loadCollection(self) -> None:
@ -1074,10 +1074,13 @@ title="{}" {}>{}</button>""".format(
self.overview = Overview(self) self.overview = Overview(self)
def setupReviewer(self) -> None: def setupReviewer(self, new: bool) -> None:
from aqt.reviewer import Reviewer 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 # Syncing
########################################################################## ##########################################################################

View file

@ -28,16 +28,33 @@ import aqt
import aqt.main import aqt.main
import aqt.operations import aqt.operations
from anki import hooks 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.decks import UpdateDeckConfigs
from anki.frontend_pb2 import PlayAVTagsRequest, ReviewerActionRequest
from anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest 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 anki.utils import dev_mode
from aqt import gui_hooks
from aqt.changenotetype import ChangeNotetypeDialog from aqt.changenotetype import ChangeNotetypeDialog
from aqt.deckoptions import DeckOptionsDialog from aqt.deckoptions import DeckOptionsDialog
from aqt.operations import on_op_finished from aqt.operations import on_op_finished
from aqt.operations.deck import update_deck_configs as update_deck_configs_op from aqt.operations.deck import update_deck_configs as update_deck_configs_op
from aqt.progress import ProgressUpdate from aqt.progress import ProgressUpdate
from aqt.qt import * 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 from aqt.utils import aqt_data_path, show_warning, tr
# https://forums.ankiweb.net/t/anki-crash-when-using-a-specific-deck/22266 # 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__) logger = logging.getLogger(__name__)
app = flask.Flask(__name__, root_path="/fake") 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 @dataclass
@ -363,6 +389,7 @@ def is_sveltekit_page(path: str) -> bool:
"import-csv", "import-csv",
"import-page", "import-page",
"image-occlusion", "image-occlusion",
"reviewer",
] ]
@ -461,6 +488,7 @@ def _extract_request(
if not aqt.mw.col: if not aqt.mw.col:
return NotFound(message=f"collection not open, ignore request for {path}") return NotFound(message=f"collection not open, ignore request for {path}")
path = path.removeprefix("media/")
path = hooks.media_file_filter(path) path = hooks.media_file_filter(path)
return LocalFileRequest(root=aqt.mw.col.media.dir(), path=path) return LocalFileRequest(root=aqt.mw.col.media.dir(), path=path)
@ -637,6 +665,136 @@ def save_custom_colours() -> bytes:
return b"" 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 = [ post_handler_list = [
congrats_info, congrats_info,
get_deck_configs_for_update, get_deck_configs_for_update,
@ -653,6 +811,11 @@ post_handler_list = [
deck_options_require_close, deck_options_require_close,
deck_options_ready, deck_options_ready,
save_custom_colours, save_custom_colours,
next_card_data,
play_avtags,
reviewer_action,
undo,
redo,
] ]
@ -660,6 +823,9 @@ exposed_backend_list = [
# CollectionService # CollectionService
"latest_progress", "latest_progress",
"get_custom_colours", "get_custom_colours",
"set_config_json",
"get_config_json",
"get_undo_status",
# DeckService # DeckService
"get_deck_names", "get_deck_names",
# I18nService # I18nService
@ -670,6 +836,9 @@ exposed_backend_list = [
# NotesService # NotesService
"get_field_names", "get_field_names",
"get_note", "get_note",
"remove_notes",
"add_note_tags",
"remove_note_tags",
# NotetypesService # NotetypesService
"get_notetype_names", "get_notetype_names",
"get_change_notetype_info", "get_change_notetype_info",
@ -695,9 +864,13 @@ exposed_backend_list = [
"get_optimal_retention_parameters", "get_optimal_retention_parameters",
"simulate_fsrs_review", "simulate_fsrs_review",
"simulate_fsrs_workload", "simulate_fsrs_workload",
"bury_or_suspend_cards",
# DeckConfigService # DeckConfigService
"get_ignored_before_count", "get_ignored_before_count",
"get_retention_workload", "get_retention_workload",
# CardsService
"set_flag",
"compare_answer",
] ]

View file

@ -138,6 +138,7 @@ class Preferences(QDialog):
form.showProgress.setChecked(reviewing.show_remaining_due_counts) form.showProgress.setChecked(reviewing.show_remaining_due_counts)
form.showPlayButtons.setChecked(not reviewing.hide_audio_play_buttons) form.showPlayButtons.setChecked(not reviewing.hide_audio_play_buttons)
form.interrupt_audio.setChecked(reviewing.interrupt_audio_when_answering) form.interrupt_audio.setChecked(reviewing.interrupt_audio_when_answering)
form.new_reviewer.setChecked(reviewing.new_reviewer)
editing = self.prefs.editing editing = self.prefs.editing
form.useCurrent.setCurrentIndex( form.useCurrent.setCurrentIndex(
@ -173,6 +174,7 @@ class Preferences(QDialog):
reviewing.time_limit_secs = form.timeLimit.value() * 60 reviewing.time_limit_secs = form.timeLimit.value() * 60
reviewing.hide_audio_play_buttons = not self.form.showPlayButtons.isChecked() reviewing.hide_audio_play_buttons = not self.form.showPlayButtons.isChecked()
reviewing.interrupt_audio_when_answering = self.form.interrupt_audio.isChecked() reviewing.interrupt_audio_when_answering = self.form.interrupt_audio.isChecked()
reviewing.new_reviewer = form.new_reviewer.isChecked()
editing = self.prefs.editing editing = self.prefs.editing
editing.adding_defaults_to_current_deck = not form.useCurrent.currentIndex() editing.adding_defaults_to_current_deck = not form.useCurrent.currentIndex()

View file

@ -172,9 +172,7 @@ class Reviewer:
gui_hooks.av_player_did_end_playing.append(self._on_av_player_did_end_playing) gui_hooks.av_player_did_end_playing.append(self._on_av_player_did_end_playing)
def show(self) -> None: def show(self) -> None:
if self.mw.col.sched_ver() == 1 or not self.mw.col.v3_scheduler(): if not self._scheduler_version_check():
self.mw.moveToState("deckBrowser")
show_warning(tr.scheduling_update_required().replace("V2", "v3"))
return return
self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore
self.web.set_bridge_command(self._linkHandler, self) self.web.set_bridge_command(self._linkHandler, self)
@ -184,6 +182,13 @@ class Reviewer:
self._refresh_needed = RefreshNeeded.QUEUES self._refresh_needed = RefreshNeeded.QUEUES
self.refresh_if_needed() 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 # this is only used by add-ons
def lastCard(self) -> Card | None: def lastCard(self) -> Card | None:
if self._answeredIds: if self._answeredIds:
@ -444,7 +449,6 @@ class Reviewer:
tooltip(tr.studying_question_time_elapsed()) tooltip(tr.studying_question_time_elapsed())
def autoplay(self, card: Card) -> bool: def autoplay(self, card: Card) -> bool:
print("use card.autoplay() instead of reviewer.autoplay(card)")
return card.autoplay() return card.autoplay()
def _update_flag_icon(self) -> None: def _update_flag_icon(self) -> None:
@ -1233,6 +1237,36 @@ timerStopped = false;
setFlag = set_flag_on_current_card 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
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 # if the last element is a comment, then the RUN_STATE_MUTATION code
# breaks due to the comment wrongly commenting out python code. # breaks due to the comment wrongly commenting out python code.
# To prevent this we put the js code on a separate line # To prevent this we put the js code on a separate line

View file

@ -211,7 +211,11 @@ class BottomWebView(ToolbarWebView):
def animate_height(self, height: int) -> None: def animate_height(self, height: int) -> None:
self.web_height = height 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) self.setFixedHeight(height)
else: else:
# Collapse/Expand animation # Collapse/Expand animation
@ -426,6 +430,7 @@ class Toolbar:
###################################################################### ######################################################################
def _linkHandler(self, link: str) -> bool: def _linkHandler(self, link: str) -> bool:
self.mw.web.setFocus()
if link in self.link_handlers: if link in self.link_handlers:
self.link_handlers[link]() self.link_handlers[link]()
return False return False
@ -457,7 +462,7 @@ class Toolbar:
###################################################################### ######################################################################
_body = """ _body = """
<div class="header"> <div class="header" onclick="pycmd('focus')">
<div class="left-tray">{left_tray_content}</div> <div class="left-tray">{left_tray_content}</div>
<div class="toolbar">{toolbar_content}</div> <div class="toolbar">{toolbar_content}</div>
<div class="right-tray">{right_tray_content}</div> <div class="right-tray">{right_tray_content}</div>

View file

@ -142,6 +142,7 @@ class AnkiWebPage(QWebEnginePage):
AnkiWebViewKind.IMPORT_ANKI_PACKAGE, AnkiWebViewKind.IMPORT_ANKI_PACKAGE,
AnkiWebViewKind.IMPORT_CSV, AnkiWebViewKind.IMPORT_CSV,
AnkiWebViewKind.IMPORT_LOG, AnkiWebViewKind.IMPORT_LOG,
AnkiWebViewKind.MAIN,
) )
global _profile_with_api_access, _profile_without_api_access global _profile_with_api_access, _profile_without_api_access

View file

@ -40,6 +40,7 @@ impl From<BoolKeyProto> for BoolKey {
BoolKeyProto::LoadBalancerEnabled => BoolKey::LoadBalancerEnabled, BoolKeyProto::LoadBalancerEnabled => BoolKey::LoadBalancerEnabled,
BoolKeyProto::FsrsShortTermWithStepsEnabled => BoolKey::FsrsShortTermWithStepsEnabled, BoolKeyProto::FsrsShortTermWithStepsEnabled => BoolKey::FsrsShortTermWithStepsEnabled,
BoolKeyProto::FsrsLegacyEvaluate => BoolKey::FsrsLegacyEvaluate, BoolKeyProto::FsrsLegacyEvaluate => BoolKey::FsrsLegacyEvaluate,
BoolKeyProto::NewReviewer => BoolKey::NewReviewer,
} }
} }
} }
@ -57,8 +58,14 @@ impl From<StringKeyProto> for StringKey {
impl crate::services::ConfigService for Collection { impl crate::services::ConfigService for Collection {
fn get_config_json(&mut self, input: generic::String) -> Result<generic::Json> { fn get_config_json(&mut self, input: generic::String) -> Result<generic::Json> {
let val: Option<Value> = self.get_config_optional(input.val.as_str()); let key = input.val.as_str();
val.or_not_found(input.val) let val: Option<Value> = 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)) .and_then(|v| serde_json::to_vec(&v).map_err(Into::into))
.map(Into::into) .map(Into::into)
} }

View file

@ -180,7 +180,7 @@ impl crate::services::CardRenderingService for Collection {
} }
} }
fn rendered_nodes_to_proto( pub(crate) fn rendered_nodes_to_proto(
nodes: Vec<RenderedNode>, nodes: Vec<RenderedNode>,
) -> Vec<anki_proto::card_rendering::RenderedTemplateNode> { ) -> Vec<anki_proto::card_rendering::RenderedTemplateNode> {
nodes nodes

View file

@ -44,6 +44,7 @@ pub enum BoolKey {
FsrsLegacyEvaluate, FsrsLegacyEvaluate,
LoadBalancerEnabled, LoadBalancerEnabled,
FsrsShortTermWithStepsEnabled, FsrsShortTermWithStepsEnabled,
NewReviewer,
#[strum(to_string = "normalize_note_text")] #[strum(to_string = "normalize_note_text")]
NormalizeNoteText, NormalizeNoteText,
#[strum(to_string = "dayLearnFirst")] #[strum(to_string = "dayLearnFirst")]

View file

@ -101,6 +101,7 @@ impl Collection {
load_balancer_enabled: self.get_config_bool(BoolKey::LoadBalancerEnabled), load_balancer_enabled: self.get_config_bool(BoolKey::LoadBalancerEnabled),
fsrs_short_term_with_steps_enabled: self fsrs_short_term_with_steps_enabled: self
.get_config_bool(BoolKey::FsrsShortTermWithStepsEnabled), .get_config_bool(BoolKey::FsrsShortTermWithStepsEnabled),
new_reviewer: self.get_config_bool(BoolKey::NewReviewer),
}) })
} }
@ -125,6 +126,7 @@ impl Collection {
BoolKey::FsrsShortTermWithStepsEnabled, BoolKey::FsrsShortTermWithStepsEnabled,
s.fsrs_short_term_with_steps_enabled, s.fsrs_short_term_with_steps_enabled,
)?; )?;
self.set_config_bool_inner(BoolKey::NewReviewer, settings.new_reviewer)?;
Ok(()) Ok(())
} }

View file

@ -4,9 +4,16 @@
mod answering; mod answering;
mod states; mod states;
use std::sync::LazyLock;
use anki_proto::cards; use anki_proto::cards;
use anki_proto::generic; use anki_proto::generic;
use anki_proto::scheduler; 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::ComputeFsrsParamsResponse;
use anki_proto::scheduler::ComputeMemoryStateResponse; use anki_proto::scheduler::ComputeMemoryStateResponse;
use anki_proto::scheduler::ComputeOptimalRetentionResponse; use anki_proto::scheduler::ComputeOptimalRetentionResponse;
@ -14,6 +21,8 @@ use anki_proto::scheduler::FsrsBenchmarkResponse;
use anki_proto::scheduler::FuzzDeltaRequest; use anki_proto::scheduler::FuzzDeltaRequest;
use anki_proto::scheduler::FuzzDeltaResponse; use anki_proto::scheduler::FuzzDeltaResponse;
use anki_proto::scheduler::GetOptimalRetentionParametersResponse; use anki_proto::scheduler::GetOptimalRetentionParametersResponse;
use anki_proto::scheduler::NextCardDataRequest;
use anki_proto::scheduler::NextCardDataResponse;
use anki_proto::scheduler::SimulateFsrsReviewRequest; use anki_proto::scheduler::SimulateFsrsReviewRequest;
use anki_proto::scheduler::SimulateFsrsReviewResponse; use anki_proto::scheduler::SimulateFsrsReviewResponse;
use anki_proto::scheduler::SimulateFsrsWorkloadResponse; use anki_proto::scheduler::SimulateFsrsWorkloadResponse;
@ -21,15 +30,20 @@ use fsrs::ComputeParametersInput;
use fsrs::FSRSItem; use fsrs::FSRSItem;
use fsrs::FSRSReview; use fsrs::FSRSReview;
use fsrs::FSRS; use fsrs::FSRS;
use regex::Regex;
use crate::backend::Backend; 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::prelude::*;
use crate::scheduler::fsrs::params::ComputeParamsRequest; use crate::scheduler::fsrs::params::ComputeParamsRequest;
use crate::scheduler::new::NewCardDueOrder; use crate::scheduler::new::NewCardDueOrder;
use crate::scheduler::states::CardState; use crate::scheduler::states::CardState;
use crate::scheduler::states::SchedulingStates; use crate::scheduler::states::SchedulingStates;
use crate::search::SortMode; use crate::search::SortMode;
use crate::services::NotesService;
use crate::stats::studied_today; use crate::stats::studied_today;
use crate::template::RenderedNode;
impl crate::services::SchedulerService for Collection { impl crate::services::SchedulerService for Collection {
/// This behaves like _updateCutoff() in older code - it also unburies at /// 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)?, delta_days: self.get_fuzz_delta(input.card_id.into(), input.interval)?,
}) })
} }
fn next_card_data(&mut self, req: NextCardDataRequest) -> Result<NextCardDataResponse> {
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() /* &nbsp */
},
})
.collect();
// Typed answer replacements
static ANSWER_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[\[type:(.+?:)?(.+?)\]\]").unwrap());
const ANSWER_HTML: &str = "<center>
<input type=text id=typeans onkeypress=\"_typeAnsPress();\"
style=\"font-family: '{self.typeFont}'; font-size: {self.typeSize}px;\">
</center>";
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: &regex::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<Vec<String>> {
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 { impl crate::services::BackendSchedulerService for Backend {

View file

@ -6,36 +6,6 @@ import type { Modifier } from "./keys";
import { checkIfModifierKey, checkModifiers, keyToPlatformString, modifiersToPlatformString } from "./keys"; import { checkIfModifierKey, checkModifiers, keyToPlatformString, modifiersToPlatformString } from "./keys";
import { registerPackage } from "./runtime-require"; 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 { function isRequiredModifier(modifier: string): boolean {
return !modifier.endsWith("?"); return !modifier.endsWith("?");
} }
@ -58,10 +28,8 @@ export function getPlatformString(keyCombinationString: string): string {
.join(", "); .join(", ");
} }
function checkKey(event: KeyboardEvent, key: number): boolean { function checkKey(event: KeyboardEvent, key: string): boolean {
// avoid deprecation warning return event.key.toLowerCase() === key.toLowerCase();
const which = event["which" + ""];
return which === key;
} }
function partition<T>(predicate: (t: T) => boolean, items: T[]): [T[], T[]] { function partition<T>(predicate: (t: T) => boolean, items: T[]): [T[], T[]] {
@ -93,25 +61,23 @@ function separateRequiredOptionalModifiers(
} }
const check = const check =
(keyCode: number, requiredModifiers: Modifier[], optionalModifiers: Modifier[]) => (key: string, requiredModifiers: Modifier[], optionalModifiers: Modifier[]) => (event: KeyboardEvent): boolean => {
(event: KeyboardEvent): boolean => {
return ( return (
checkKey(event, keyCode) checkKey(event, key)
&& checkModifiers(requiredModifiers, optionalModifiers)(event) && checkModifiers(requiredModifiers, optionalModifiers)(event)
); );
}; };
function keyToCode(key: string): number {
return keyCodeLookup[key] || key.toUpperCase().charCodeAt(0);
}
function keyCombinationToCheck( function keyCombinationToCheck(
keyCombination: string[], keyCombination: string[],
): (event: KeyboardEvent) => boolean { ): (event: KeyboardEvent) => boolean {
const keyCode = keyToCode(keyCombination[keyCombination.length - 1]); const keyCode = keyCombination[keyCombination.length - 1];
const [required, optional] = separateRequiredOptionalModifiers( const [required, optional] = separateRequiredOptionalModifiers(
keyCombination.slice(0, -1), keyCombination.slice(0, -1),
); );
if ("@*!=".includes(keyCode)) {
optional.push("Shift");
}
return check(keyCode, required, optional); return check(keyCode, required, optional);
} }

View file

@ -36,7 +36,7 @@ export function getTypedAnswer(): string | null {
return typeans?.value ?? null; return typeans?.value ?? null;
} }
function _runHook( export function _runHook(
hooks: Array<Callback>, hooks: Array<Callback>,
): Promise<PromiseSettledResult<void | Promise<void>>[]> { ): Promise<PromiseSettledResult<void | Promise<void>>[]> {
const promises: (Promise<void> | void)[] = []; const promises: (Promise<void> | void)[] = [];
@ -104,7 +104,7 @@ async function setInnerHTML(element: Element, html: string): Promise<void> {
} }
} }
const renderError = (type: string) => (error: unknown): string => { export const renderError = (type: string) => (error: unknown): string => {
const errorMessage = String(error).substring(0, 2000); const errorMessage = String(error).substring(0, 2000);
let errorStack: string; let errorStack: string;
if (error instanceof Error) { if (error instanceof Error) {

View file

@ -1,4 +1,4 @@
@import "$lib/sass/base"; @import "../lib/sass/base";
// override Bootstrap transition duration // override Bootstrap transition duration
$carousel-transition: var(--transition); $carousel-transition: var(--transition);
@ -11,8 +11,8 @@ $carousel-transition: var(--transition);
@import "bootstrap/scss/close"; @import "bootstrap/scss/close";
@import "bootstrap/scss/alert"; @import "bootstrap/scss/alert";
@import "bootstrap/scss/badge"; @import "bootstrap/scss/badge";
@import "$lib/sass/bootstrap-forms"; @import "../lib/sass/bootstrap-forms";
@import "$lib/sass/bootstrap-tooltip"; @import "../lib/sass/bootstrap-tooltip";
input[type="text"], input[type="text"],
input[type="date"], input[type="date"],

View file

@ -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<void>;
export const onUpdateHook: Array<Callback> = [];
export const onShownHook: Array<Callback> = [];
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<InnerReviewerRequest>) => {
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,
});

View file

@ -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;

View file

@ -0,0 +1,38 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import "./index.scss";
import { onMount } from "svelte";
import { ReviewerState, updateNightMode } from "./reviewer";
import ReviewerBottom from "./reviewer-bottom/ReviewerBottom.svelte";
import Reviewer from "./Reviewer.svelte";
import { _blockDefaultDragDropBehavior } from "../../reviewer";
import type { PageData } from "./$types";
export let data: PageData;
const state = new ReviewerState();
onMount(() => {
updateNightMode();
globalThis.anki ??= {};
globalThis.anki.changeReceived = () => state.showQuestion(null);
_blockDefaultDragDropBehavior();
state.undoStatus = data.initialUndoStatus;
});
</script>
<div>
<Reviewer {state}></Reviewer>
<ReviewerBottom {state}></ReviewerBottom>
</div>
<style>
div {
height: 100vh;
display: flex;
flex-direction: column;
}
</style>

View file

@ -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;

View file

@ -0,0 +1,66 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { isNightMode, type ReviewerState } from "./reviewer";
let iframe: HTMLIFrameElement;
export let state: ReviewerState;
$: if (iframe) {
state.registerIFrame(iframe);
state.registerShortcuts();
}
$: tooltipMessage = state.tooltipMessage;
$: tooltipShown = state.tooltipShown;
$: flag = state.flag;
$: marked = state.marked;
</script>
<div class="iframe-container">
<iframe
src={"/_anki/pages/reviewer-inner.html" + (isNightMode() ? "?nightMode" : "")}
bind:this={iframe}
title="card"
frameborder="0"
sandbox="allow-scripts"
></iframe>
<div class="tooltip" style:opacity={$tooltipShown ? 1 : 0}>
{$tooltipMessage}
</div>
</div>
{#if $flag}
<div id="_flag" style:color={`var(--flag-${$flag})`}>⚑</div>
{/if}
{#if $marked}
<div id="_mark"></div>
{/if}
<style lang="scss">
div.iframe-container {
position: relative;
flex: 1;
}
div.tooltip {
position: absolute;
left: 0;
bottom: 0;
padding: 0.8em;
background-color: var(--bs-tooltip-color);
z-index: var(--bs-tooltip-z-index);
border: 2px solid var(--highlight-fg);
opacity: 1;
transition: opacity 0.3s;
}
iframe {
width: 100%;
height: 100%;
visibility: hidden;
}
</style>

View file

@ -0,0 +1 @@
@use "../../reviewer/reviewer.scss";

View file

View file

@ -0,0 +1,37 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type { NextCardDataResponse_AnswerButton } from "@generated/anki/scheduler_pb";
import * as tr from "@generated/ftl";
import type { ReviewerState } from "../reviewer";
export let info: NextCardDataResponse_AnswerButton;
export let state: ReviewerState;
const labels = [
tr.studyingAgain(),
tr.studyingHard(),
tr.studyingGood(),
tr.studyingEasy(),
];
$: label = labels[info.rating];
</script>
<span>
{#if info.due}
{info.due}
{:else}
&nbsp;
{/if}
</span>
<button on:click={() => state.easeButtonPressed(info.rating)}>
{label}
</button>
<style>
span {
text-align: center;
}
</style>

View file

@ -0,0 +1,269 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "@generated/ftl";
import MoreSubmenu from "./MoreSubmenu.svelte";
import MoreItem from "./MoreItem.svelte";
import type { ReviewerState } from "../reviewer";
import type { MoreMenuItemInfo } from "./types";
import Shortcut from "$lib/components/Shortcut.svelte";
let showFloating = false;
let showFlags = false;
export let state: ReviewerState;
const flags = [
{ colour: tr.actionsFlagRed(), shortcut: "Ctrl+1" },
{ colour: tr.actionsFlagOrange(), shortcut: "Ctrl+2" },
{ colour: tr.actionsFlagGreen(), shortcut: "Ctrl+3" },
{ colour: tr.actionsFlagBlue(), shortcut: "Ctrl+4" },
{ colour: tr.actionsFlagPink(), shortcut: "Ctrl+5" },
{ colour: tr.actionsFlagTurquoise(), shortcut: "Ctrl+6" },
{ colour: tr.actionsFlagPurple(), shortcut: "Ctrl+7" },
];
const shortcuts: MoreMenuItemInfo[] = [
{
name: tr.studyingBuryCard(),
shortcut: "-",
onClick: state.buryOrSuspendCurrentCard.bind(state, false),
},
{
name: tr.actionsForgetCard(),
shortcut: "Ctrl+Alt+N",
onClick: state.displayForgetMenu.bind(state),
},
{
name: tr.actionsSetDueDate(),
shortcut: "Ctrl+Shift+D",
onClick: state.displaySetDueDateMenu.bind(state),
},
{
name: tr.actionsSuspendCard(),
shortcut: "@",
onClick: state.buryOrSuspendCurrentCard.bind(state, true),
},
{
name: tr.actionsOptions(),
shortcut: "O",
onClick: state.displayOptionsMenu.bind(state),
},
{
name: tr.actionsCardInfo(),
shortcut: "I",
onClick: state.displayCardInfoMenu.bind(state),
},
{
name: tr.actionsPreviousCardInfo(),
shortcut: "Ctrl+Alt+I",
onClick: state.displayPreviousCardInfoMenu.bind(state),
},
"hr",
// Notes
{
name: tr.studyingMarkNote(),
shortcut: "*",
onClick: state.toggleMarked.bind(state),
},
{
name: tr.studyingBuryNote(),
shortcut: "=",
onClick: state.buryOrSuspendCurrentNote.bind(state, false),
},
{
name: tr.studyingSuspendNote(),
shortcut: "!",
onClick: state.buryOrSuspendCurrentNote.bind(state, true),
},
{
name: tr.actionsCreateCopy(),
shortcut: "Ctrl+Alt+E",
onClick: state.displayCreateCopyMenu.bind(state),
},
{
name: tr.studyingDeleteNote(),
shortcut:
/* FIXME: (I have no apple devices to test it) isMac ? "Ctrl+Backspace" :*/ "Ctrl+Delete",
onClick: state.deleteCurrentNote.bind(state),
},
"hr",
// Audio
{
name: tr.actionsReplayAudio(),
shortcut: "R",
onClick: state.replayAudio.bind(state),
},
{
name: tr.studyingPauseAudio(),
shortcut: "5",
onClick: state.pauseAudio.bind(state),
},
{
name: tr.studyingAudio5s(),
shortcut: "6",
onClick: state.AudioSeekBackward.bind(state),
},
{
name: tr.studyingAudioAnd5s(),
shortcut: "7",
onClick: state.AudioSeekForward.bind(state),
},
{
name: tr.studyingRecordOwnVoice(),
shortcut: "Shift+V",
onClick: state.RecordVoice.bind(state),
},
{
name: tr.studyingReplayOwnVoice(),
shortcut: "V",
onClick: state.ReplayRecorded.bind(state),
},
{
name: tr.actionsAutoAdvance(),
shortcut: "Shift+A",
onClick: () => state.toggleAutoAdvance(),
},
];
$: currentFlag = state.flag;
$: autoAdvance = state.autoAdvance;
function prepKeycodeForShortcut(keycode: string) {
return keycode.replace("Ctrl", "Control");
}
$: if (!showFloating) {
showFlags = false;
}
</script>
<Shortcut
keyCombination="m"
event="keyup"
on:action={() => (showFloating = !showFloating)}
/>
{#each shortcuts as shortcut}
{#if shortcut !== "hr"}
<Shortcut
keyCombination={prepKeycodeForShortcut(shortcut.shortcut)}
event="keydown"
on:action={shortcut.onClick}
/>
{/if}
{/each}
{#each flags as flag, i}
<Shortcut
keyCombination={prepKeycodeForShortcut(flag.shortcut)}
event="keydown"
on:action={() => {
state.changeFlag(i + 1);
}}
/>
{/each}
<MoreSubmenu bind:showFloating>
<button
slot="button"
on:click={() => {
showFloating = !showFloating;
}}
title={tr.actionsShortcutKey({ val: "M" })}
class="more"
>
{tr.studyingMore()}
</button>
<div slot="items" class="dropdown">
<div class="row">
<MoreSubmenu bind:showFloating={showFlags}>
<MoreItem
slot="button"
submenu
on:click={() => {
showFlags = !showFlags;
}}
>
{tr.studyingFlagCard()}
</MoreItem>
<div slot="items" class="dropdown">
{#each flags as flag, i}
{@const flag_id = i + 1}
<div
style:background-color={$currentFlag == flag_id
? `color-mix(in srgb, var(--flag-${flag_id}) 50%, transparent)`
: ""}
>
<MoreItem
shortcut={flag.shortcut}
on:click={() => {
state.changeFlag(flag_id);
}}
>
{flag.colour}
</MoreItem>
</div>
{/each}
</div>
</MoreSubmenu>
</div>
{#each shortcuts as shortcut}
{#if shortcut == "hr"}
<hr />
{:else}
{@const highlighted = shortcut.shortcut == "Shift+A" && $autoAdvance}
<div style:background-color={highlighted ? "RGBA(0,255,0,0.25)" : ""}>
<MoreItem
shortcut={shortcut.shortcut}
on:click={shortcut.onClick}
on:keydown={shortcut.onClick}
on:action={console.log}
>
{shortcut.name}
</MoreItem>
</div>
{/if}
{/each}
</div>
</MoreSubmenu>
<style lang="scss">
div.dropdown {
:global(button) {
border-radius: 0;
padding: 0.5em;
margin: 0;
&:hover {
background: inherit;
color: inherit;
}
}
display: flex;
flex-direction: column;
flex-wrap: wrap;
max-height: 90vh;
}
.more::after {
content: " ▾ ";
:global(.isWin) & {
content: " ▼ ";
}
}
hr {
margin: 0;
}
button {
line-height: 18px;
}
</style>

View file

@ -0,0 +1,46 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
export let shortcut: string = "";
export let submenu: boolean = false;
</script>
<div class="row" on:click on:keydown role="button" tabindex="0">
<slot />
<span>{shortcut}</span>
{#if submenu}
<span class="chevron">{"▸"}</span>
{/if}
</div>
<style lang="scss">
div.row {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 0.5em;
cursor: pointer;
line-height: normal;
gap: 0.5em;
&:hover {
background: var(--highlight-bg);
color: var(--highlight-fg);
}
span {
min-width: 7em;
}
}
.chevron {
text-align: right;
padding-right: 0.5em;
opacity: 50%;
:global(.isLin) & {
transform: translateY(-1px);
}
}
</style>

View file

@ -0,0 +1,42 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Popover from "$lib/components/Popover.svelte";
import WithFloating from "$lib/components/WithFloating.svelte";
import { onMount } from "svelte";
export let showFloating = false;
function close() {
showFloating = false;
}
onMount(() => {
document.addEventListener("closemenu", close);
window.addEventListener("blur", close);
return () => {
document.removeEventListener("closemenu", close);
window.removeEventListener("blur", close);
};
});
</script>
<div>
<WithFloating show={showFloating} inline on:close={close}>
<slot slot="reference" name="button"></slot>
<Popover slot="floating">
<slot name="items" />
</Popover>
</WithFloating>
</div>
<style>
div :global(.popover) {
padding: 0;
max-height: 100vh;
}
</style>

View file

@ -0,0 +1,40 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { QueuedCards_Queue } from "@generated/anki/scheduler_pb";
import type { ReviewerState } from "../reviewer";
import RemainingNumber from "./RemainingNumber.svelte";
export let state: ReviewerState;
const cardData = state.cardData;
$: queue = $cardData?.queue;
$: underlined = queue?.cards[0].queue;
</script>
<span>
<RemainingNumber
cls="new-count"
underlined={underlined === QueuedCards_Queue.NEW}
count={queue?.newCount}
></RemainingNumber>
{"+"}
<RemainingNumber
cls="learn-count"
underlined={underlined === QueuedCards_Queue.LEARNING}
count={queue?.learningCount}
></RemainingNumber>
{"+"}
<RemainingNumber
cls="review-count"
underlined={underlined === QueuedCards_Queue.REVIEW}
count={queue?.reviewCount}
></RemainingNumber>
</span>
<style>
span {
text-align: center;
}
</style>

View file

@ -0,0 +1,19 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
export let underlined: boolean;
export let cls: string;
export let count: number | undefined;
$: displayCount = count?.toFixed(0) ?? "?";
</script>
<span class={cls}>
{#if underlined}
<u>{displayCount}</u>
{:else}
{displayCount}
{/if}
</span>

View file

@ -0,0 +1,101 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import "./index.scss";
import AnswerButton from "./AnswerButton.svelte";
import * as tr from "@generated/ftl";
import type { ReviewerState } from "../reviewer";
import Remaining from "./Remaining.svelte";
import More from "./More.svelte";
import Timer from "./Timer.svelte";
export let state: ReviewerState;
const answerButtons = state.answerButtons;
const answerShown = state.answerShown;
$: buttonCount = $answerShown ? $answerButtons.length : 1;
$: cardData = state.cardData;
$: remainingShown =
($cardData?.queue?.learningCount ?? 0) +
($cardData?.queue?.reviewCount ?? 0) +
($cardData?.queue?.newCount ?? 0) >
0;
</script>
<div id="outer" class="fancy">
<div id="tableinner" style="--answer-button-count: {buttonCount}">
<span class="disappearing"></span>
<div class="disappearing edit">
<button
title={tr.actionsShortcutKey({ val: "E" })}
on:click={() => state.displayEditMenu()}
>
{tr.studyingEdit()}
</button>
</div>
{#if $answerShown}
{#each $answerButtons as answerButton}
<AnswerButton {state} info={answerButton}></AnswerButton>
{/each}
{:else}
{#if remainingShown}
<Remaining {state}></Remaining>
{:else}
<span>&nbsp;</span>
{/if}
<button on:click={() => state.showAnswer()}>
{tr.studyingShowAnswer()}
</button>
{/if}
<div class="disappearing more">
{#if $cardData?.timer}
<Timer {state}></Timer>
{/if}
</div>
<div class="disappearing more">
<More {state}></More>
</div>
</div>
</div>
<style lang="scss">
#tableinner {
width: 100%;
display: grid;
grid-template-columns: 1fr repeat(var(--answer-button-count, 1), auto) 1fr;
grid-template-rows: auto auto;
justify-content: space-between;
justify-items: center;
align-items: center;
grid-auto-flow: column;
}
#outer {
padding: 8px;
}
.more,
.edit {
width: 100%;
}
.more {
display: flex;
justify-content: flex-end;
}
@media (max-width: 583px) {
.disappearing {
display: none;
}
#tableinner {
grid-template-columns: repeat(var(--answer-button-count, 1), auto);
justify-content: center;
}
}
</style>

View file

@ -0,0 +1,67 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type { ReviewerState } from "../reviewer";
import { onDestroy } from "svelte";
export let state: ReviewerState;
let text = "";
let cls = "";
function step() {
const timerPreferences = state._cardData?.timer;
let time = Date.now();
if (timerPreferences?.stopOnAnswer && state.answerMs !== undefined) {
time = state.answerMs;
}
time -= state.beginAnsweringMs;
const maxTime = state._cardData?.timer?.maxTimeMs ?? 0;
if (time >= maxTime) {
time = maxTime;
cls = "overtime";
} else {
cls = "";
}
text = formatTime(time);
}
let interval: ReturnType<typeof setInterval> | undefined = undefined;
function startTimer() {
clearInterval(interval);
interval = setInterval(step, 1000);
text = formatTime(0);
cls = "";
}
state.cardData.subscribe(startTimer);
onDestroy(() => {
clearInterval(interval);
});
function formatTime(time: number) {
const seconds = time / 1000;
return `${Math.floor(seconds / 60)
.toFixed(0)
.padStart(2, "0")}:${(seconds % 60).toFixed(0).padStart(2, "0")}`;
}
</script>
<div class={cls}>
{text}
</div>
<style>
div {
width: 88px;
text-align: center;
}
.overtime {
color: red;
}
</style>

View file

@ -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;
}
}

View file

@ -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";

View file

@ -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<NextCardDataResponse_NextCardData | undefined>(undefined);
readonly answerButtons = derived(this.cardData, ($cardData) => $cardData?.answerButtons ?? []);
tooltipMessageTimeout: ReturnType<typeof setTimeout> | 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<typeof setTimeout> | undefined;
autoAdvanceAnswerTimeout: ReturnType<typeof setTimeout> | 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<ReviewerRequest>) {
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") ? "<br/>" : "";
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,
}),
);
}
}

View file

@ -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;