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"
],
)?;
build_page(
"reviewer-inner",
true,
inputs![
//
":ts:lib",
":ts:components",
":sass",
":sveltekit"
],
)?;
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-without-shift-key-strips-formatting = Paste without shift key strips formatting
preferences-generate-latex-images-automatically = Generate LaTeX images (security risk)
preferences-use-new-reviewer = Use new reviewer
preferences-latex-generation-disabled = LaTeX image generation is disabled in the preferences.
preferences-periodically-sync-media = Periodically sync media
preferences-please-restart-anki-to-complete-language = Please restart Anki to complete language change.

View file

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

View file

@ -10,6 +10,7 @@ package anki.frontend;
import "anki/scheduler.proto";
import "anki/generic.proto";
import "anki/search.proto";
import "anki/card_rendering.proto";
service FrontendService {
// Returns values from the reviewer
@ -30,6 +31,10 @@ service FrontendService {
// Save colour picker's custom colour palette
rpc SaveCustomColours(generic.Empty) returns (generic.Empty);
// Plays the listed AV tags
rpc PlayAVTags(PlayAVTagsRequest) returns (generic.Empty);
rpc ReviewerAction(ReviewerActionRequest) returns (generic.Empty);
}
service BackendFrontendService {}
@ -43,3 +48,35 @@ message SetSchedulingStatesRequest {
string key = 1;
scheduler.SchedulingStates states = 2;
}
message PlayAVTagsRequest {
repeated card_rendering.AVTag tags = 1;
}
message ReviewerActionRequest {
enum ReviewerAction {
// Menus
EditCurrent = 0;
SetDueDate = 1;
CardInfo = 2;
PreviousCardInfo = 3;
CreateCopy = 4;
// Reset
Forget = 5;
// Preset Options
Options = 6;
// "Congratulations"
Overview = 7;
// Audio
PauseAudio = 9;
SeekBackward = 10;
SeekForward = 11;
RecordVoice = 12;
ReplayRecorded = 13;
};
ReviewerAction menu = 1;
// In case the card isn't set in a next_card_data intercept function
optional int64 current_card_id = 2;
}

View file

@ -13,10 +13,12 @@ import "anki/decks.proto";
import "anki/collection.proto";
import "anki/config.proto";
import "anki/deck_config.proto";
import "anki/card_rendering.proto";
service SchedulerService {
rpc GetQueuedCards(GetQueuedCardsRequest) returns (QueuedCards);
rpc AnswerCard(CardAnswer) returns (collection.OpChanges);
rpc NextCardData(NextCardDataRequest) returns (NextCardDataResponse);
rpc SchedTimingToday(generic.Empty) returns (SchedTimingTodayResponse);
rpc StudiedToday(generic.Empty) returns (generic.String);
rpc StudiedTodayMessage(StudiedTodayMessageRequest) returns (generic.String);
@ -285,6 +287,66 @@ message CardAnswer {
uint32 milliseconds_taken = 6;
}
message NextCardDataRequest {
optional CardAnswer answer = 1;
}
message NextCardDataResponse {
message AnswerButton {
CardAnswer.Rating rating = 1;
string due = 2;
}
message TypedAnswer {
string text = 1;
string args = 2;
}
message TimerPreferences {
uint32 max_time_ms = 1;
bool stop_on_answer = 2;
}
message PartialTemplate {
repeated card_rendering.RenderedTemplateNode front = 1;
repeated card_rendering.RenderedTemplateNode back = 2;
}
message NextCardData {
QueuedCards queue = 1;
repeated AnswerButton answer_buttons = 2;
string front = 3;
string back = 4;
string css = 5;
string body_class = 6;
bool autoplay = 7;
bool marked = 13;
optional TypedAnswer typed_answer = 12;
optional TimerPreferences timer = 14;
// TODO: Is it worth setting up some sort of "ReviewerPreferences" endpoint
// akin to GetGraphPreferences Also should this reviewer setting be moved to
// a config bool rather than config.meta
bool accept_enter = 15;
repeated card_rendering.AVTag question_av_tags = 8;
repeated card_rendering.AVTag answer_av_tags = 9;
float autoAdvanceQuestionSeconds = 16;
float autoAdvanceAnswerSeconds = 17;
bool autoAdvanceWaitForAudio = 20;
deck_config.DeckConfig.Config.QuestionAction autoAdvanceQuestionAction = 18;
deck_config.DeckConfig.Config.AnswerAction autoAdvanceAnswerAction = 19;
optional PartialTemplate partialTemplate = 11;
}
optional NextCardData next_card = 1;
// For media pre-loading. The fields of the note after next_card.
string preload = 2;
}
message CustomStudyRequest {
message Cram {
enum CramKind {

View file

@ -81,4 +81,4 @@ button {
#outer {
border-top-color: color(border-subtle);
}
}
}

View file

@ -451,6 +451,19 @@
</property>
</widget>
</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>
<widget class="QPushButton" name="url_schemes">
<property name="text">

View file

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

View file

@ -28,16 +28,33 @@ import aqt
import aqt.main
import aqt.operations
from anki import hooks
from anki.collection import OpChanges, OpChangesOnly, Progress, SearchNode
from anki.cards import Card, CardId
from anki.collection import (
OpChanges,
OpChangesOnly,
Progress,
SearchNode,
)
from anki.decks import UpdateDeckConfigs
from anki.frontend_pb2 import PlayAVTagsRequest, ReviewerActionRequest
from anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest
from anki.scheduler_pb2 import NextCardDataRequest, NextCardDataResponse
from anki.template import (
PartiallyRenderedCard,
TemplateRenderContext,
apply_custom_filters,
av_tags_to_native,
)
from anki.utils import dev_mode
from aqt import gui_hooks
from aqt.changenotetype import ChangeNotetypeDialog
from aqt.deckoptions import DeckOptionsDialog
from aqt.operations import on_op_finished
from aqt.operations.deck import update_deck_configs as update_deck_configs_op
from aqt.progress import ProgressUpdate
from aqt.qt import *
from aqt.sound import av_player
from aqt.theme import ThemeManager
from aqt.utils import aqt_data_path, show_warning, tr
# https://forums.ankiweb.net/t/anki-crash-when-using-a-specific-deck/22266
@ -45,7 +62,16 @@ waitress.wasyncore._DISCONNECTED = waitress.wasyncore._DISCONNECTED.union({EPROT
logger = logging.getLogger(__name__)
app = flask.Flask(__name__, root_path="/fake")
flask_cors.CORS(app, resources={r"/*": {"origins": "127.0.0.1"}})
flask_cors.CORS(
app,
resources={
r"/_anki/js/vendor/mathjax/output/chtml/fonts/woff-v2/.*.woff": {
"origins": "*"
},
r"/media/.*": {"origins": "*"},
r"/*": {"origins": "127.0.0.1"},
},
)
@dataclass
@ -363,6 +389,7 @@ def is_sveltekit_page(path: str) -> bool:
"import-csv",
"import-page",
"image-occlusion",
"reviewer",
]
@ -461,6 +488,7 @@ def _extract_request(
if not aqt.mw.col:
return NotFound(message=f"collection not open, ignore request for {path}")
path = path.removeprefix("media/")
path = hooks.media_file_filter(path)
return LocalFileRequest(root=aqt.mw.col.media.dir(), path=path)
@ -637,6 +665,136 @@ def save_custom_colours() -> bytes:
return b""
theme_manager = ThemeManager()
def next_card_data() -> bytes:
raw = aqt.mw.col._backend.next_card_data_raw(request.data)
data = NextCardDataResponse.FromString(raw)
av_player.stop_and_clear_queue()
aqt.mw.update_undo_actions()
if len(data.next_card.queue.cards) == 0:
card = None
else:
backend_card = data.next_card.queue.cards[0].card
card = Card(aqt.mw.col, backend_card=backend_card)
# TODO: Is dealing with gui_hooks in mediasrv like this a good idea?
if gui_hooks.reviewer_did_answer_card.count() > 0:
req = NextCardDataRequest.FromString(request.data)
if req.HasField("answer"):
aqt.mw.taskman.run_on_main(
lambda: gui_hooks.reviewer_did_answer_card(
aqt.mw.reviewer,
aqt.mw.col.get_card(CardId(req.answer.card_id)),
req.answer.rating + 1, # type: ignore
)
)
reviewer = aqt.mw.reviewer
# This if statement prevents refreshes from causing the previous card to update.
if reviewer.card is None or card is None or card.id != reviewer.card.id:
reviewer.previous_card = reviewer.card
reviewer.card = card
def update_card_info():
reviewer._previous_card_info.set_card(reviewer.previous_card)
reviewer._card_info.set_card(card)
aqt.mw.taskman.run_on_main(update_card_info)
if card is None:
return data.SerializeToString()
ctx = TemplateRenderContext.from_existing_card(card, False)
qside = apply_custom_filters(
PartiallyRenderedCard.nodes_from_proto(data.next_card.partialTemplate.front),
ctx,
None,
)
aside = apply_custom_filters(
PartiallyRenderedCard.nodes_from_proto(data.next_card.partialTemplate.back),
ctx,
qside,
)
# Dont send the partialy rendered template to the frontend to save bandwidth
data.next_card.ClearField("partialTemplate")
q_avtags = ctx.col()._backend.extract_av_tags(text=qside, question_side=True)
a_avtags = ctx.col()._backend.extract_av_tags(text=aside, question_side=False)
# Assumes the av tags are empty in the original response
data.next_card.question_av_tags.extend(q_avtags.av_tags)
data.next_card.answer_av_tags.extend(a_avtags.av_tags)
qside = q_avtags.text
aside = a_avtags.text
qside = aqt.mw.prepare_card_text_for_display(qside)
aside = aqt.mw.prepare_card_text_for_display(aside)
data.next_card.front = qside
data.next_card.back = aside
# Night mode is handled by the frontend so that it works with the browsers theme if used outside of anki.
# Perhaps the OS class should be handled this way too?
data.next_card.body_class = theme_manager.body_classes_for_card_ord(card.ord, False)
data.next_card.accept_enter = aqt.mw.pm.spacebar_rates_card()
return data.SerializeToString()
def play_avtags():
req = PlayAVTagsRequest.FromString(request.data)
av_player.play_tags(av_tags_to_native(req.tags))
return b""
def reviewer_action():
reviewer = aqt.mw.reviewer
ACTION_ENUM = ReviewerActionRequest.ReviewerAction
def overview():
aqt.mw.moveToState("overview")
REVIEWER_ACTIONS = {
ACTION_ENUM.EditCurrent: aqt.mw.onEditCurrent,
ACTION_ENUM.SetDueDate: reviewer.on_set_due,
ACTION_ENUM.CardInfo: reviewer.on_card_info,
ACTION_ENUM.PreviousCardInfo: reviewer.on_previous_card_info,
ACTION_ENUM.CreateCopy: reviewer.on_create_copy,
ACTION_ENUM.Forget: reviewer.forget_current_card,
ACTION_ENUM.Options: reviewer.onOptions,
ACTION_ENUM.Overview: overview,
ACTION_ENUM.PauseAudio: reviewer.on_pause_audio,
ACTION_ENUM.SeekBackward: reviewer.on_seek_backward,
ACTION_ENUM.SeekForward: reviewer.on_seek_forward,
ACTION_ENUM.RecordVoice: reviewer.onRecordVoice,
ACTION_ENUM.ReplayRecorded: reviewer.onReplayRecorded,
}
req = ReviewerActionRequest.FromString(request.data)
aqt.mw.taskman.run_on_main(REVIEWER_ACTIONS[req.menu])
return b""
def undo_redo(action: str):
resp = raw_backend_request(action)()
aqt.mw.update_undo_actions()
return resp
def undo():
return undo_redo("undo")
def redo():
return undo_redo("redo")
post_handler_list = [
congrats_info,
get_deck_configs_for_update,
@ -653,6 +811,11 @@ post_handler_list = [
deck_options_require_close,
deck_options_ready,
save_custom_colours,
next_card_data,
play_avtags,
reviewer_action,
undo,
redo,
]
@ -660,6 +823,9 @@ exposed_backend_list = [
# CollectionService
"latest_progress",
"get_custom_colours",
"set_config_json",
"get_config_json",
"get_undo_status",
# DeckService
"get_deck_names",
# I18nService
@ -670,6 +836,9 @@ exposed_backend_list = [
# NotesService
"get_field_names",
"get_note",
"remove_notes",
"add_note_tags",
"remove_note_tags",
# NotetypesService
"get_notetype_names",
"get_change_notetype_info",
@ -695,9 +864,13 @@ exposed_backend_list = [
"get_optimal_retention_parameters",
"simulate_fsrs_review",
"simulate_fsrs_workload",
"bury_or_suspend_cards",
# DeckConfigService
"get_ignored_before_count",
"get_retention_workload",
# CardsService
"set_flag",
"compare_answer",
]

View file

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

View file

@ -172,9 +172,7 @@ class Reviewer:
gui_hooks.av_player_did_end_playing.append(self._on_av_player_did_end_playing)
def show(self) -> None:
if self.mw.col.sched_ver() == 1 or not self.mw.col.v3_scheduler():
self.mw.moveToState("deckBrowser")
show_warning(tr.scheduling_update_required().replace("V2", "v3"))
if not self._scheduler_version_check():
return
self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore
self.web.set_bridge_command(self._linkHandler, self)
@ -184,6 +182,13 @@ class Reviewer:
self._refresh_needed = RefreshNeeded.QUEUES
self.refresh_if_needed()
def _scheduler_version_check(self):
if self.mw.col.sched_ver() == 1 or not self.mw.col.v3_scheduler():
self.mw.moveToState("deckBrowser")
show_warning(tr.scheduling_update_required().replace("V2", "v3"))
return False
return True
# this is only used by add-ons
def lastCard(self) -> Card | None:
if self._answeredIds:
@ -444,7 +449,6 @@ class Reviewer:
tooltip(tr.studying_question_time_elapsed())
def autoplay(self, card: Card) -> bool:
print("use card.autoplay() instead of reviewer.autoplay(card)")
return card.autoplay()
def _update_flag_icon(self) -> None:
@ -1233,6 +1237,36 @@ timerStopped = false;
setFlag = set_flag_on_current_card
class SvelteReviewer(Reviewer):
def refresh_if_needed(self):
if self._refresh_needed:
self.mw.fade_in_webview()
self.web.eval("if (anki) {anki.changeReceived()}")
self._refresh_needed = None
def show(self) -> None:
if not self._scheduler_version_check():
return
self._initWeb()
# Prevents the shortcuts selecting the toolbar buttons for the next time enter is pressed
self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore
def _linkHandler(self, url: str) -> None:
pass
def _initWeb(self) -> None:
self._reps = 0
# hide the bottom bar
self.bottom.web.setHtml("<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
# breaks due to the comment wrongly commenting out python code.
# 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:
self.web_height = height
if self.mw.pm.reduce_motion() or height == self.height():
if (
self.mw.pm.reduce_motion()
or self.mw.col.conf.get("newReviewer")
or height == self.height()
):
self.setFixedHeight(height)
else:
# Collapse/Expand animation
@ -426,6 +430,7 @@ class Toolbar:
######################################################################
def _linkHandler(self, link: str) -> bool:
self.mw.web.setFocus()
if link in self.link_handlers:
self.link_handlers[link]()
return False
@ -457,7 +462,7 @@ class Toolbar:
######################################################################
_body = """
<div class="header">
<div class="header" onclick="pycmd('focus')">
<div class="left-tray">{left_tray_content}</div>
<div class="toolbar">{toolbar_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_CSV,
AnkiWebViewKind.IMPORT_LOG,
AnkiWebViewKind.MAIN,
)
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::FsrsShortTermWithStepsEnabled => BoolKey::FsrsShortTermWithStepsEnabled,
BoolKeyProto::FsrsLegacyEvaluate => BoolKey::FsrsLegacyEvaluate,
BoolKeyProto::NewReviewer => BoolKey::NewReviewer,
}
}
}
@ -57,8 +58,14 @@ impl From<StringKeyProto> for StringKey {
impl crate::services::ConfigService for Collection {
fn get_config_json(&mut self, input: generic::String) -> Result<generic::Json> {
let val: Option<Value> = self.get_config_optional(input.val.as_str());
val.or_not_found(input.val)
let key = input.val.as_str();
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))
.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>,
) -> Vec<anki_proto::card_rendering::RenderedTemplateNode> {
nodes

View file

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

View file

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

View file

@ -4,9 +4,16 @@
mod answering;
mod states;
use std::sync::LazyLock;
use anki_proto::cards;
use anki_proto::generic;
use anki_proto::scheduler;
use anki_proto::scheduler::next_card_data_response::AnswerButton;
use anki_proto::scheduler::next_card_data_response::NextCardData;
use anki_proto::scheduler::next_card_data_response::PartialTemplate;
use anki_proto::scheduler::next_card_data_response::TimerPreferences;
use anki_proto::scheduler::next_card_data_response::TypedAnswer;
use anki_proto::scheduler::ComputeFsrsParamsResponse;
use anki_proto::scheduler::ComputeMemoryStateResponse;
use anki_proto::scheduler::ComputeOptimalRetentionResponse;
@ -14,6 +21,8 @@ use anki_proto::scheduler::FsrsBenchmarkResponse;
use anki_proto::scheduler::FuzzDeltaRequest;
use anki_proto::scheduler::FuzzDeltaResponse;
use anki_proto::scheduler::GetOptimalRetentionParametersResponse;
use anki_proto::scheduler::NextCardDataRequest;
use anki_proto::scheduler::NextCardDataResponse;
use anki_proto::scheduler::SimulateFsrsReviewRequest;
use anki_proto::scheduler::SimulateFsrsReviewResponse;
use anki_proto::scheduler::SimulateFsrsWorkloadResponse;
@ -21,15 +30,20 @@ use fsrs::ComputeParametersInput;
use fsrs::FSRSItem;
use fsrs::FSRSReview;
use fsrs::FSRS;
use regex::Regex;
use crate::backend::Backend;
use crate::card_rendering::service::rendered_nodes_to_proto;
use crate::cloze::extract_cloze_for_typing;
use crate::prelude::*;
use crate::scheduler::fsrs::params::ComputeParamsRequest;
use crate::scheduler::new::NewCardDueOrder;
use crate::scheduler::states::CardState;
use crate::scheduler::states::SchedulingStates;
use crate::search::SortMode;
use crate::services::NotesService;
use crate::stats::studied_today;
use crate::template::RenderedNode;
impl crate::services::SchedulerService for Collection {
/// This behaves like _updateCutoff() in older code - it also unburies at
@ -382,6 +396,145 @@ impl crate::services::SchedulerService for Collection {
delta_days: self.get_fuzz_delta(input.card_id.into(), input.interval)?,
})
}
fn next_card_data(&mut self, req: NextCardDataRequest) -> Result<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 {

View file

@ -6,36 +6,6 @@ import type { Modifier } from "./keys";
import { checkIfModifierKey, checkModifiers, keyToPlatformString, modifiersToPlatformString } from "./keys";
import { registerPackage } from "./runtime-require";
const keyCodeLookup = {
Backspace: 8,
Delete: 46,
Tab: 9,
Enter: 13,
F1: 112,
F2: 113,
F3: 114,
F4: 115,
F5: 116,
F6: 117,
F7: 118,
F8: 119,
F9: 120,
F10: 121,
F11: 122,
F12: 123,
"=": 187,
"-": 189,
"[": 219,
"]": 221,
"\\": 220,
";": 186,
"'": 222,
",": 188,
".": 190,
"/": 191,
"`": 192,
};
function isRequiredModifier(modifier: string): boolean {
return !modifier.endsWith("?");
}
@ -58,10 +28,8 @@ export function getPlatformString(keyCombinationString: string): string {
.join(", ");
}
function checkKey(event: KeyboardEvent, key: number): boolean {
// avoid deprecation warning
const which = event["which" + ""];
return which === key;
function checkKey(event: KeyboardEvent, key: string): boolean {
return event.key.toLowerCase() === key.toLowerCase();
}
function partition<T>(predicate: (t: T) => boolean, items: T[]): [T[], T[]] {
@ -93,25 +61,23 @@ function separateRequiredOptionalModifiers(
}
const check =
(keyCode: number, requiredModifiers: Modifier[], optionalModifiers: Modifier[]) =>
(event: KeyboardEvent): boolean => {
(key: string, requiredModifiers: Modifier[], optionalModifiers: Modifier[]) => (event: KeyboardEvent): boolean => {
return (
checkKey(event, keyCode)
checkKey(event, key)
&& checkModifiers(requiredModifiers, optionalModifiers)(event)
);
};
function keyToCode(key: string): number {
return keyCodeLookup[key] || key.toUpperCase().charCodeAt(0);
}
function keyCombinationToCheck(
keyCombination: string[],
): (event: KeyboardEvent) => boolean {
const keyCode = keyToCode(keyCombination[keyCombination.length - 1]);
const keyCode = keyCombination[keyCombination.length - 1];
const [required, optional] = separateRequiredOptionalModifiers(
keyCombination.slice(0, -1),
);
if ("@*!=".includes(keyCode)) {
optional.push("Shift");
}
return check(keyCode, required, optional);
}

View file

@ -36,7 +36,7 @@ export function getTypedAnswer(): string | null {
return typeans?.value ?? null;
}
function _runHook(
export function _runHook(
hooks: Array<Callback>,
): Promise<PromiseSettledResult<void | Promise<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);
let errorStack: string;
if (error instanceof Error) {

View file

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

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;