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" ":sveltekit"
], ],
)?; )?;
build_page(
"reviewer-inner",
true,
inputs![
//
":ts:lib",
":ts:components",
":sass",
":sveltekit"
],
)?;
Ok(()) Ok(())
} }

View file

@ -14,6 +14,7 @@ preferences-on-next-sync-force-changes-in = On next sync, force changes in one d
preferences-paste-clipboard-images-as-png = Paste clipboard images as PNG preferences-paste-clipboard-images-as-png = Paste clipboard images as PNG
preferences-paste-without-shift-key-strips-formatting = Paste without shift key strips formatting preferences-paste-without-shift-key-strips-formatting = Paste without shift key strips formatting
preferences-generate-latex-images-automatically = Generate LaTeX images (security risk) preferences-generate-latex-images-automatically = Generate LaTeX images (security risk)
preferences-use-new-reviewer = Use new reviewer
preferences-latex-generation-disabled = LaTeX image generation is disabled in the preferences. preferences-latex-generation-disabled = LaTeX image generation is disabled in the preferences.
preferences-periodically-sync-media = Periodically sync media preferences-periodically-sync-media = Periodically sync media
preferences-please-restart-anki-to-complete-language = Please restart Anki to complete language change. preferences-please-restart-anki-to-complete-language = Please restart Anki to complete language change.

View file

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

View file

@ -10,6 +10,7 @@ package anki.frontend;
import "anki/scheduler.proto"; import "anki/scheduler.proto";
import "anki/generic.proto"; import "anki/generic.proto";
import "anki/search.proto"; import "anki/search.proto";
import "anki/card_rendering.proto";
service FrontendService { service FrontendService {
// Returns values from the reviewer // Returns values from the reviewer
@ -30,6 +31,9 @@ service FrontendService {
// Save colour picker's custom colour palette // Save colour picker's custom colour palette
rpc SaveCustomColours(generic.Empty) returns (generic.Empty); rpc SaveCustomColours(generic.Empty) returns (generic.Empty);
// Plays the listed AV tags
rpc PlayAVTags(PlayAVTagsRequest) returns (generic.Empty);
} }
service BackendFrontendService {} service BackendFrontendService {}
@ -43,3 +47,7 @@ message SetSchedulingStatesRequest {
string key = 1; string key = 1;
scheduler.SchedulingStates states = 2; 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/collection.proto";
import "anki/config.proto"; import "anki/config.proto";
import "anki/deck_config.proto"; import "anki/deck_config.proto";
import "anki/card_rendering.proto";
service SchedulerService { service SchedulerService {
rpc GetQueuedCards(GetQueuedCardsRequest) returns (QueuedCards); rpc GetQueuedCards(GetQueuedCardsRequest) returns (QueuedCards);
rpc AnswerCard(CardAnswer) returns (collection.OpChanges); rpc AnswerCard(CardAnswer) returns (collection.OpChanges);
rpc NextCardData(NextCardDataRequest) returns (NextCardDataResponse);
rpc SchedTimingToday(generic.Empty) returns (SchedTimingTodayResponse); rpc SchedTimingToday(generic.Empty) returns (SchedTimingTodayResponse);
rpc StudiedToday(generic.Empty) returns (generic.String); rpc StudiedToday(generic.Empty) returns (generic.String);
rpc StudiedTodayMessage(StudiedTodayMessageRequest) returns (generic.String); rpc StudiedTodayMessage(StudiedTodayMessageRequest) returns (generic.String);
@ -285,6 +287,43 @@ message CardAnswer {
uint32 milliseconds_taken = 6; uint32 milliseconds_taken = 6;
} }
message NextCardDataRequest {
optional CardAnswer answer = 1;
}
message NextCardDataResponse {
message AnswerButton {
CardAnswer.Rating rating = 1;
string due = 2;
}
message 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 CustomStudyRequest {
message Cram { message Cram {
enum CramKind { enum CramKind {

View file

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

View file

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

View file

@ -28,9 +28,18 @@ import aqt
import aqt.main import aqt.main
import aqt.operations import aqt.operations
from anki import hooks from anki import hooks
from anki.cards import Card
from anki.collection import OpChanges, OpChangesOnly, Progress, SearchNode from anki.collection import OpChanges, OpChangesOnly, Progress, SearchNode
from anki.decks import UpdateDeckConfigs from anki.decks import UpdateDeckConfigs
from anki.frontend_pb2 import PlayAVTagsRequest
from anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest 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 anki.utils import dev_mode
from aqt.changenotetype import ChangeNotetypeDialog from aqt.changenotetype import ChangeNotetypeDialog
from aqt.deckoptions import DeckOptionsDialog 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.operations.deck import update_deck_configs as update_deck_configs_op
from aqt.progress import ProgressUpdate from aqt.progress import ProgressUpdate
from aqt.qt import * from aqt.qt import *
from aqt.sound import play_tags
from aqt.theme import ThemeManager
from aqt.utils import aqt_data_path, show_warning, tr from aqt.utils import aqt_data_path, show_warning, tr
# https://forums.ankiweb.net/t/anki-crash-when-using-a-specific-deck/22266 # https://forums.ankiweb.net/t/anki-crash-when-using-a-specific-deck/22266
@ -363,6 +374,7 @@ def is_sveltekit_page(path: str) -> bool:
"import-csv", "import-csv",
"import-page", "import-page",
"image-occlusion", "image-occlusion",
"reviewer",
] ]
@ -637,6 +649,55 @@ def save_custom_colours() -> bytes:
return b"" return b""
theme_manager = ThemeManager()
def next_card_data() -> bytes:
raw = aqt.mw.col._backend.next_card_data_raw(request.data)
data = NextCardDataResponse.FromString(raw)
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 = [ post_handler_list = [
congrats_info, congrats_info,
get_deck_configs_for_update, get_deck_configs_for_update,
@ -653,6 +714,8 @@ post_handler_list = [
deck_options_require_close, deck_options_require_close,
deck_options_ready, deck_options_ready,
save_custom_colours, save_custom_colours,
next_card_data,
play_avtags,
] ]
@ -698,6 +761,9 @@ exposed_backend_list = [
# DeckConfigService # DeckConfigService
"get_ignored_before_count", "get_ignored_before_count",
"get_retention_workload", "get_retention_workload",
# CardsService
"set_flag",
"compare_answer",
] ]

View file

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

View file

@ -1233,6 +1233,79 @@ timerStopped = false;
setFlag = set_flag_on_current_card 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 # if the last element is a comment, then the RUN_STATE_MUTATION code
# breaks due to the comment wrongly commenting out python code. # breaks due to the comment wrongly commenting out python code.
# To prevent this we put the js code on a separate line # To prevent this we put the js code on a separate line

View file

@ -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.""" """eg. if pycmd is 'play:q:0', play the first audio on the question side."""
play, context, str_idx = pycmd.split(":") play, context, str_idx = pycmd.split(":")
idx = int(str_idx) idx = int(str_idx)
if context == "q": tags = card.question_av_tags() if context == "q" else card.answer_av_tags()
tags = card.question_av_tags() play_tags([tags[idx]])
else:
tags = card.answer_av_tags()
av_player.play_tags([tags[idx]])
play_tags = av_player.play_tags
# Init defaults # Init defaults
########################################################################## ##########################################################################

View file

@ -211,7 +211,11 @@ class BottomWebView(ToolbarWebView):
def animate_height(self, height: int) -> None: def animate_height(self, height: int) -> None:
self.web_height = height self.web_height = height
if self.mw.pm.reduce_motion() or height == self.height(): if (
self.mw.pm.reduce_motion()
or self.mw.col.conf.get("newReviewer")
or height == self.height()
):
self.setFixedHeight(height) self.setFixedHeight(height)
else: else:
# Collapse/Expand animation # Collapse/Expand animation

View file

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

View file

@ -40,6 +40,7 @@ impl From<BoolKeyProto> for BoolKey {
BoolKeyProto::LoadBalancerEnabled => BoolKey::LoadBalancerEnabled, BoolKeyProto::LoadBalancerEnabled => BoolKey::LoadBalancerEnabled,
BoolKeyProto::FsrsShortTermWithStepsEnabled => BoolKey::FsrsShortTermWithStepsEnabled, BoolKeyProto::FsrsShortTermWithStepsEnabled => BoolKey::FsrsShortTermWithStepsEnabled,
BoolKeyProto::FsrsLegacyEvaluate => BoolKey::FsrsLegacyEvaluate, BoolKeyProto::FsrsLegacyEvaluate => BoolKey::FsrsLegacyEvaluate,
BoolKeyProto::NewReviewer => BoolKey::NewReviewer,
} }
} }
} }

View file

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

View file

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

View file

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

View file

@ -4,9 +4,13 @@
mod answering; mod answering;
mod states; mod states;
use std::sync::LazyLock;
use anki_proto::cards; use anki_proto::cards;
use anki_proto::generic; use anki_proto::generic;
use anki_proto::scheduler; use anki_proto::scheduler;
use anki_proto::scheduler::next_card_data_response::AnswerButton;
use anki_proto::scheduler::next_card_data_response::NextCardData;
use anki_proto::scheduler::ComputeFsrsParamsResponse; use anki_proto::scheduler::ComputeFsrsParamsResponse;
use anki_proto::scheduler::ComputeMemoryStateResponse; use anki_proto::scheduler::ComputeMemoryStateResponse;
use anki_proto::scheduler::ComputeOptimalRetentionResponse; use anki_proto::scheduler::ComputeOptimalRetentionResponse;
@ -14,6 +18,8 @@ use anki_proto::scheduler::FsrsBenchmarkResponse;
use anki_proto::scheduler::FuzzDeltaRequest; use anki_proto::scheduler::FuzzDeltaRequest;
use anki_proto::scheduler::FuzzDeltaResponse; use anki_proto::scheduler::FuzzDeltaResponse;
use anki_proto::scheduler::GetOptimalRetentionParametersResponse; use anki_proto::scheduler::GetOptimalRetentionParametersResponse;
use anki_proto::scheduler::NextCardDataRequest;
use anki_proto::scheduler::NextCardDataResponse;
use anki_proto::scheduler::SimulateFsrsReviewRequest; use anki_proto::scheduler::SimulateFsrsReviewRequest;
use anki_proto::scheduler::SimulateFsrsReviewResponse; use anki_proto::scheduler::SimulateFsrsReviewResponse;
use anki_proto::scheduler::SimulateFsrsWorkloadResponse; use anki_proto::scheduler::SimulateFsrsWorkloadResponse;
@ -21,15 +27,19 @@ use fsrs::ComputeParametersInput;
use fsrs::FSRSItem; use fsrs::FSRSItem;
use fsrs::FSRSReview; use fsrs::FSRSReview;
use fsrs::FSRS; use fsrs::FSRS;
use regex::Regex;
use crate::backend::Backend; use crate::backend::Backend;
use crate::card_rendering::service::rendered_nodes_to_proto;
use crate::prelude::*; use crate::prelude::*;
use crate::scheduler::fsrs::params::ComputeParamsRequest; use crate::scheduler::fsrs::params::ComputeParamsRequest;
use crate::scheduler::new::NewCardDueOrder; use crate::scheduler::new::NewCardDueOrder;
use crate::scheduler::states::CardState; use crate::scheduler::states::CardState;
use crate::scheduler::states::SchedulingStates; use crate::scheduler::states::SchedulingStates;
use crate::search::SortMode; use crate::search::SortMode;
use crate::services::NotesService;
use crate::stats::studied_today; use crate::stats::studied_today;
use crate::template::RenderedNode;
impl crate::services::SchedulerService for Collection { impl crate::services::SchedulerService for Collection {
/// This behaves like _updateCutoff() in older code - it also unburies at /// This behaves like _updateCutoff() in older code - it also unburies at
@ -382,6 +392,89 @@ impl crate::services::SchedulerService for Collection {
delta_days: self.get_fuzz_delta(input.card_id.into(), input.interval)?, delta_days: self.get_fuzz_delta(input.card_id.into(), input.interval)?,
}) })
} }
fn next_card_data(&mut self, req: NextCardDataRequest) -> Result<NextCardDataResponse> {
if let Some(answer) = req.answer {
self.answer_card(&mut answer.into())?;
}
let 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 { 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); const errorMessage = String(error).substring(0, 2000);
let errorStack: string; let errorStack: string;
if (error instanceof Error) { if (error instanceof Error) {

View file

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

View file

@ -0,0 +1,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;