mirror of
https://github.com/ankitects/anki.git
synced 2025-11-06 04:37:22 -05:00
Merge 9011cf87ff into dac26ce671
This commit is contained in:
commit
94289b2480
37 changed files with 1032 additions and 18 deletions
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -81,4 +81,4 @@ button {
|
|||
#outer {
|
||||
border-top-color: color(border-subtle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
##########################################################################
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
##########################################################################
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ pub enum BoolKey {
|
|||
FsrsLegacyEvaluate,
|
||||
LoadBalancerEnabled,
|
||||
FsrsShortTermWithStepsEnabled,
|
||||
NewReviewer,
|
||||
#[strum(to_string = "normalize_note_text")]
|
||||
NormalizeNoteText,
|
||||
#[strum(to_string = "dayLearnFirst")]
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: ®ex::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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
96
ts/routes/reviewer-inner/index.ts
Normal file
96
ts/routes/reviewer-inner/index.ts
Normal 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;
|
||||
10
ts/routes/reviewer-inner/innerReviewerRequest.ts
Normal file
10
ts/routes/reviewer-inner/innerReviewerRequest.ts
Normal 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;
|
||||
30
ts/routes/reviewer/+page.svelte
Normal file
30
ts/routes/reviewer/+page.svelte
Normal 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>
|
||||
37
ts/routes/reviewer/Reviewer.svelte
Normal file
37
ts/routes/reviewer/Reviewer.svelte
Normal 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>
|
||||
0
ts/routes/reviewer/index.ts
Normal file
0
ts/routes/reviewer/index.ts
Normal file
37
ts/routes/reviewer/reviewer-bottom/AnswerButton.svelte
Normal file
37
ts/routes/reviewer/reviewer-bottom/AnswerButton.svelte
Normal 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}
|
||||
|
||||
{/if}
|
||||
</span>
|
||||
<button on:click={() => state.easeButtonPressed(info.rating)}>
|
||||
{label}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
span {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
75
ts/routes/reviewer/reviewer-bottom/More.svelte
Normal file
75
ts/routes/reviewer/reviewer-bottom/More.svelte
Normal 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>
|
||||
14
ts/routes/reviewer/reviewer-bottom/MoreItem.svelte
Normal file
14
ts/routes/reviewer/reviewer-bottom/MoreItem.svelte
Normal 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>
|
||||
20
ts/routes/reviewer/reviewer-bottom/MoreSubmenu.svelte
Normal file
20
ts/routes/reviewer/reviewer-bottom/MoreSubmenu.svelte
Normal 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>
|
||||
40
ts/routes/reviewer/reviewer-bottom/Remaining.svelte
Normal file
40
ts/routes/reviewer/reviewer-bottom/Remaining.svelte
Normal 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>
|
||||
19
ts/routes/reviewer/reviewer-bottom/RemainingNumber.svelte
Normal file
19
ts/routes/reviewer/reviewer-bottom/RemainingNumber.svelte
Normal 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>
|
||||
86
ts/routes/reviewer/reviewer-bottom/ReviewerBottom.svelte
Normal file
86
ts/routes/reviewer/reviewer-bottom/ReviewerBottom.svelte
Normal 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>
|
||||
15
ts/routes/reviewer/reviewer-bottom/index.scss
Normal file
15
ts/routes/reviewer/reviewer-bottom/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
9
ts/routes/reviewer/reviewer-bottom/types.ts
Normal file
9
ts/routes/reviewer/reviewer-bottom/types.ts
Normal 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;
|
||||
}
|
||||
187
ts/routes/reviewer/reviewer.ts
Normal file
187
ts/routes/reviewer/reviewer.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
19
ts/routes/reviewer/reviewerRequest.ts
Normal file
19
ts/routes/reviewer/reviewerRequest.ts
Normal 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;
|
||||
Loading…
Reference in a new issue