This commit is contained in:
Luc Mcgrady 2025-11-04 22:14:51 +00:00 committed by GitHub
commit 94289b2480
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1032 additions and 18 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,9 @@ 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);
}
service BackendFrontendService {}
@ -43,3 +47,7 @@ message SetSchedulingStatesRequest {
string key = 1;
scheduler.SchedulingStates states = 2;
}
message PlayAVTagsRequest {
repeated card_rendering.AVTag tags = 1;
}

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,43 @@ message CardAnswer {
uint32 milliseconds_taken = 6;
}
message NextCardDataRequest {
optional CardAnswer answer = 1;
}
message NextCardDataResponse {
message AnswerButton {
CardAnswer.Rating rating = 1;
string due = 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;
optional string typed_answer = 12;
optional string typed_answer_args = 13;
repeated card_rendering.AVTag question_av_tags = 8;
repeated card_rendering.AVTag answer_av_tags = 9;
// TODO: We can probably make this a little faster by using oneof and
// preventing the partial_front and back being sent to svelte where it isn't
// used. Alternatively we can use a completely different message for both
// Rust -> Python and the Python -> Svelte though this would be more
// complicated to implement.
repeated card_rendering.RenderedTemplateNode partial_front = 10;
repeated card_rendering.RenderedTemplateNode partial_back = 11;
}
optional NextCardData next_card = 1;
}
message CustomStudyRequest {
message Cram {
enum CramKind {

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,9 +28,18 @@ import aqt
import aqt.main
import aqt.operations
from anki import hooks
from anki.cards import Card
from anki.collection import OpChanges, OpChangesOnly, Progress, SearchNode
from anki.decks import UpdateDeckConfigs
from anki.frontend_pb2 import PlayAVTagsRequest
from anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest
from anki.scheduler_pb2 import NextCardDataResponse
from anki.template import (
PartiallyRenderedCard,
TemplateRenderContext,
apply_custom_filters,
av_tags_to_native,
)
from anki.utils import dev_mode
from aqt.changenotetype import ChangeNotetypeDialog
from aqt.deckoptions import DeckOptionsDialog
@ -38,6 +47,8 @@ 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 play_tags
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
@ -363,6 +374,7 @@ def is_sveltekit_page(path: str) -> bool:
"import-csv",
"import-page",
"image-occlusion",
"reviewer",
]
@ -637,6 +649,55 @@ 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)
backend_card = data.next_card.queue.cards[0].card
card = Card(aqt.mw.col, backend_card=backend_card)
ctx = TemplateRenderContext.from_existing_card(card, False)
qside = apply_custom_filters(
PartiallyRenderedCard.nodes_from_proto(data.next_card.partial_front),
ctx,
None,
)
aside = apply_custom_filters(
PartiallyRenderedCard.nodes_from_proto(data.next_card.partial_back),
ctx,
qside,
)
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)
return data.SerializeToString()
def play_avtags():
req = PlayAVTagsRequest.FromString(request.data)
play_tags(av_tags_to_native(req.tags))
post_handler_list = [
congrats_info,
get_deck_configs_for_update,
@ -653,6 +714,8 @@ post_handler_list = [
deck_options_require_close,
deck_options_ready,
save_custom_colours,
next_card_data,
play_avtags,
]
@ -698,6 +761,9 @@ exposed_backend_list = [
# 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,8 @@ 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()
aqt.mw.setupReviewer(reviewing.new_reviewer)
editing = self.prefs.editing
editing.adding_defaults_to_current_deck = not form.useCurrent.currentIndex()

View file

@ -1233,6 +1233,79 @@ timerStopped = false;
setFlag = set_flag_on_current_card
class SvelteReviewer(Reviewer):
def _answerButtons(self) -> str:
default = self._defaultEase()
assert isinstance(self.mw.col.sched, V3Scheduler)
labels = self.mw.col.sched.describe_next_states(self._v3.states)
def but(i: int, label: str):
if i == default:
id = "defease"
else:
id = ""
due = self._buttonTime(i, v3_labels=labels)
key = (
tr.actions_shortcut_key(val=aqt.mw.pm.get_answer_key(i))
if aqt.mw.pm.get_answer_key(i)
else ""
)
return {
"id": id,
"key": key,
"i": i,
"label": label,
"due": due,
}
return [but(ease, label) for ease, label in self._answerButtonList()] # type: ignore
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:
self._initWeb()
def _remaining(self) -> str:
if not self.mw.col.conf["dueCounts"]:
return ""
idx, counts = self._v3.counts()
self.web.eval(f"_updateRemaining({json.dumps(counts)},{idx})")
return ""
def _showAnswerButton(self) -> None:
if self.card.should_show_timer():
maxTime = self.card.time_limit() / 1000
else:
maxTime = 0
self._remaining()
self.web.eval('showQuestion("",%d);' % (maxTime))
def _buttonTime(self, i: int, v3_labels: Sequence[str]) -> str:
return v3_labels[i - 1] if self.mw.col.conf["estTimes"] else ""
def _linkHandler(self, url: str) -> None:
pass
def _initWeb(self) -> None:
self._reps = 0
# hide the bottom bar
self.bottom.web.setHtml("<style>body {margin:0;} html {height:0;}</style>")
# main window
self.web.load_sveltekit_page("reviewer")
# block default drag & drop behavior while allowing drop events to be received by JS handlers
self.web.allow_drops = True
self.web.eval("_blockDefaultDragDropBehavior();")
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

@ -925,13 +925,12 @@ def play_clicked_audio(pycmd: str, card: Card) -> None:
"""eg. if pycmd is 'play:q:0', play the first audio on the question side."""
play, context, str_idx = pycmd.split(":")
idx = int(str_idx)
if context == "q":
tags = card.question_av_tags()
else:
tags = card.answer_av_tags()
av_player.play_tags([tags[idx]])
tags = card.question_av_tags() if context == "q" else card.answer_av_tags()
play_tags([tags[idx]])
play_tags = av_player.play_tags
# Init defaults
##########################################################################

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

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,
}
}
}

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,13 @@
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::ComputeFsrsParamsResponse;
use anki_proto::scheduler::ComputeMemoryStateResponse;
use anki_proto::scheduler::ComputeOptimalRetentionResponse;
@ -14,6 +18,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 +27,19 @@ 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::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 +392,89 @@ 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 queue = self.get_queued_cards(1, false)?;
let next_card = queue.cards.first();
if let Some(next_card) = next_card {
let cid = next_card.card.id;
let render = self.render_existing_card(cid, false, true)?;
let answer_buttons = self
.describe_next_states(&next_card.states)?
.into_iter()
.enumerate()
.map(|(i, due)| AnswerButton {
rating: i as i32,
due,
})
.collect();
let config = self.deck_config_for_card(&next_card.card)?;
// 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()),
cap[2].to_string(),
));
ANSWER_HTML
})
.to_string();
out
} else {
None
}
});
let typed_answer = typed_answer_parent_node.as_ref().map(|field| {
let note = self.get_note(next_card.card.note_id.into()).unwrap();
let notetype = self.get_notetype(note.notetype_id.into()).unwrap().unwrap();
note.fields[notetype.get_field_ord(&field.1).unwrap()].clone()
});
Ok(NextCardDataResponse {
next_card: Some(NextCardData {
queue: Some(queue.into()),
css: render.css.clone(),
partial_front: rendered_nodes_to_proto(q_nodes),
partial_back: rendered_nodes_to_proto(render.anodes),
answer_buttons,
autoplay: !config.inner.disable_autoplay,
typed_answer,
typed_answer_args: typed_answer_parent_node.and_then(|v| v.0),
// Filled by python
front: "".to_string(),
back: "".to_string(),
body_class: "".to_string(),
question_av_tags: vec![],
answer_av_tags: vec![],
}),
})
} else {
Ok(NextCardDataResponse::default())
}
}
}
impl crate::services::BackendSchedulerService for Backend {

View file

@ -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,96 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import "../base.scss";
import "../../reviewer/reviewer.scss";
import "mathjax/es5/tex-chtml-full.js";
import { renderError } from "../../reviewer";
import { enableNightMode } from "../reviewer/reviewer";
import type { ReviewerRequest } from "../reviewer/reviewerRequest";
import type { InnerReviewerRequest } from "./innerReviewerRequest";
function postParentMessage(message: ReviewerRequest) {
window.parent.postMessage(
message,
"*",
);
}
declare const MathJax: any;
const urlParams = new URLSearchParams(location.search);
const style = document.createElement("style");
document.head.appendChild(style);
addEventListener("message", async (e: MessageEvent<InnerReviewerRequest>) => {
switch (e.data.type) {
case "html": {
document.body.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");
}
}
// 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"));
break;
}
default: {
console.warn(`Unknown message type: ${e.data.type}`);
break;
}
}
});
addEventListener("keydown", (e) => {
if (e.key === "Enter") {
postParentMessage({ type: "keypress", key: " " });
} else if (
e.key.length == 1 && "1234 ".includes(e.key)
&& !document.activeElement?.matches("input[type=text], input[type=number], textarea")
) {
postParentMessage({ type: "keypress", key: e.key });
}
});
const base = document.createElement("base");
base.href = "/";
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;

View file

@ -0,0 +1,10 @@
// 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;
}
export type InnerReviewerRequest = HtmlMessage;

View file

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

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 { isNightMode, type ReviewerState } from "./reviewer";
let iframe: HTMLIFrameElement;
export let state: ReviewerState;
$: if (iframe) {
state.registerIFrame(iframe);
state.registerShortcuts();
}
</script>
<div id="qa">
<iframe
src={"/_anki/pages/reviewer-inner.html" + (isNightMode() ? "?nightMode" : "")}
bind:this={iframe}
title="card"
frameborder="0"
sandbox="allow-scripts"
></iframe>
</div>
<style lang="scss">
#qa {
flex: 1;
}
iframe {
width: 100%;
height: 100%;
visibility: hidden;
}
</style>

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,75 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import DropdownItem from "$lib/components/DropdownItem.svelte";
import * as tr from "@generated/ftl";
import MoreSubmenu from "./MoreSubmenu.svelte";
import MoreItem from "./MoreItem.svelte";
import { setFlag } from "@generated/backend";
import type { ReviewerState } from "../reviewer";
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" },
];
function changeFlag(index: number) {
setFlag({ cardIds: [state.currentCard!.card!.id], flag: index });
}
</script>
<MoreSubmenu bind:showFloating>
<button
slot="button"
on:click={() => {
showFloating = !showFloating;
}}
title={tr.actionsShortcutKey({ val: "M" })}
>
{tr.studyingMore()}{"▾"}
</button>
<div slot="items">
<MoreSubmenu bind:showFloating={showFlags}>
<DropdownItem
slot="button"
on:click={() => {
showFlags = !showFlags;
}}
>
{tr.studyingFlagCard()}
</DropdownItem>
<div slot="items">
{#each flags as flag, i}
<MoreItem
shortcut={flag.shortcut}
onClick={() => changeFlag(i + 1)}
>
{flag.colour}
</MoreItem>
{/each}
</div>
</MoreSubmenu>
</div>
</MoreSubmenu>
<style>
div :global(button) {
width: fit-content;
}
button {
line-height: 18px;
}
</style>

View file

@ -0,0 +1,14 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import DropdownItem from "$lib/components/DropdownItem.svelte";
import Shortcut from "$lib/components/Shortcut.svelte";
export let shortcut: string = "";
export let onClick = () => {};
</script>
<Shortcut keyCombination={shortcut} on:keydown={onClick}></Shortcut>
<DropdownItem on:click={onClick}><slot /></DropdownItem>

View file

@ -0,0 +1,20 @@
<!--
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";
export let showFloating = false;
</script>
<div>
<WithFloating show={showFloating} inline on:close={() => (showFloating = false)}>
<slot slot="reference" name="button"></slot>
<Popover slot="floating">
<slot name="items" />
</Popover>
</WithFloating>
</div>

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,86 @@
<!--
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 { bridgeCommand } from "@tslib/bridgecommand";
import * as tr from "@generated/ftl";
import type { ReviewerState } from "../reviewer";
import Remaining from "./Remaining.svelte";
import More from "./More.svelte";
export let state: ReviewerState;
const answerButtons = state.answerButtons;
const answerShown = state.answerShown;
$: button_count = $answerShown ? $answerButtons.length : 1;
</script>
<div id="outer" class="fancy">
<div id="tableinner" style="--answer-button-count: {button_count}">
<span class="disappearing"></span>
<div class="disappearing edit">
<button
title={tr.actionsShortcutKey({ val: "E" })}
on:click={() => bridgeCommand("edit")}
>
{tr.studyingEdit()}
</button>
</div>
{#if $answerShown}
{#each $answerButtons as answerButton}
<AnswerButton {state} info={answerButton}></AnswerButton>
{/each}
{:else}
<Remaining {state}></Remaining>
<button on:click={() => state.showAnswer()}>
{tr.studyingShowAnswer()}
</button>
{/if}
<span class="disappearing"></span>
<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 {
text-align: right;
}
@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,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,9 @@
// 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;
}

View file

@ -0,0 +1,187 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { CardAnswer, type NextCardDataResponse_NextCardData } from "@generated/anki/scheduler_pb";
import { compareAnswer, nextCardData, playAvtags } from "@generated/backend";
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;
export class ReviewerState {
answerHtml = "";
currentTypedAnswer = "";
_cardData: NextCardDataResponse_NextCardData | undefined = undefined;
beginAnsweringMs = Date.now();
readonly cardClass = writable("");
readonly answerShown = writable(false);
readonly cardData = writable<NextCardDataResponse_NextCardData | undefined>(undefined);
readonly answerButtons = derived(this.cardData, ($cardData) => $cardData?.answerButtons ?? []);
iframe: HTMLIFrameElement | undefined = undefined;
onReady() {
this.iframe!.style.visibility = "visible";
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;
playAvtags({ tags: [tags[e.data.index]] });
break;
}
case "typed": {
this.currentTypedAnswer = e.data.value;
break;
}
case "keypress": {
this.handleKeyPress(e.data.key);
break;
}
}
}
public registerIFrame(iframe: HTMLIFrameElement) {
this.iframe = iframe;
iframe.addEventListener("load", this.onReady.bind(this));
}
handleKeyPress(key: string) {
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 " ": {
if (!get(this.answerShown)) {
this.showAnswer();
} else {
this.easeButtonPressed(2);
}
break;
}
}
}
onKeyDown(e: KeyboardEvent) {
this.handleKeyPress(e.key);
}
public registerShortcuts() {
document.addEventListener("keydown", this.onKeyDown.bind(this));
}
sendInnerRequest(message: InnerReviewerRequest) {
this.iframe?.contentWindow?.postMessage(message, "*");
}
updateHtml(htmlString: string, css?: string, bodyclass?: string) {
this.sendInnerRequest({ type: "html", value: htmlString, css, bodyclass });
}
async showQuestion(answer: CardAnswer | null) {
const resp = await nextCardData({
answer: answer || undefined,
});
// TODO: "Congratulation screen" logic
this._cardData = resp.nextCard;
this.cardData.set(this._cardData);
this.answerShown.set(false);
const question = resp.nextCard?.front || "";
this.updateHtml(question, resp?.nextCard?.css, resp?.nextCard?.bodyClass);
if (this._cardData?.autoplay) {
playAvtags({ tags: this._cardData!.questionAvTags });
}
this.beginAnsweringMs = Date.now();
}
get currentCard() {
return this._cardData?.queue?.cards[0];
}
async showTypedAnswer(html: string) {
if (!this._cardData?.typedAnswer || !this._cardData.typedAnswerArgs) {
return html;
}
const compareAnswerResp = await compareAnswer({
expected: this._cardData?.typedAnswer,
provided: this.currentTypedAnswer,
combining: !this._cardData.typedAnswerArgs.includes("nc"),
});
const display = compareAnswerResp.val;
console.log({ typedAnswerRegex, html, display });
return html.replace(typedAnswerRegex, display);
}
public async showAnswer() {
this.answerShown.set(true);
if (this._cardData?.autoplay) {
playAvtags({ tags: this._cardData!.answerAvTags });
}
this.updateHtml(await this.showTypedAnswer(this._cardData?.back || ""));
}
public easeButtonPressed(rating: number) {
if (!get(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,19 @@
// 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";
key: string;
}
export type ReviewerRequest = AudioMessage | UpdateTypedAnswerMessage | KeyPressMessage;