diff --git a/build/configure/src/web.rs b/build/configure/src/web.rs
index ef2d268bb..fd169ac22 100644
--- a/build/configure/src/web.rs
+++ b/build/configure/src/web.rs
@@ -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(())
}
diff --git a/ftl/core/preferences.ftl b/ftl/core/preferences.ftl
index 23b72f267..a702cca4a 100644
--- a/ftl/core/preferences.ftl
+++ b/ftl/core/preferences.ftl
@@ -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.
diff --git a/proto/anki/config.proto b/proto/anki/config.proto
index ea115f0fc..f7a04e38d 100644
--- a/proto/anki/config.proto
+++ b/proto/anki/config.proto
@@ -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;
diff --git a/proto/anki/frontend.proto b/proto/anki/frontend.proto
index 1d733a369..bac47c8d2 100644
--- a/proto/anki/frontend.proto
+++ b/proto/anki/frontend.proto
@@ -10,6 +10,7 @@ package anki.frontend;
import "anki/scheduler.proto";
import "anki/generic.proto";
import "anki/search.proto";
+import "anki/card_rendering.proto";
service FrontendService {
// Returns values from the reviewer
@@ -30,6 +31,10 @@ service FrontendService {
// Save colour picker's custom colour palette
rpc SaveCustomColours(generic.Empty) returns (generic.Empty);
+
+ // Plays the listed AV tags
+ rpc PlayAVTags(PlayAVTagsRequest) returns (generic.Empty);
+ rpc ReviewerAction(ReviewerActionRequest) returns (generic.Empty);
}
service BackendFrontendService {}
@@ -43,3 +48,35 @@ message SetSchedulingStatesRequest {
string key = 1;
scheduler.SchedulingStates states = 2;
}
+
+message PlayAVTagsRequest {
+ repeated card_rendering.AVTag tags = 1;
+}
+
+message ReviewerActionRequest {
+ enum ReviewerAction {
+ // Menus
+ EditCurrent = 0;
+ SetDueDate = 1;
+ CardInfo = 2;
+ PreviousCardInfo = 3;
+ CreateCopy = 4;
+ // Reset
+ Forget = 5;
+ // Preset Options
+ Options = 6;
+ // "Congratulations"
+ Overview = 7;
+
+ // Audio
+ PauseAudio = 9;
+ SeekBackward = 10;
+ SeekForward = 11;
+ RecordVoice = 12;
+ ReplayRecorded = 13;
+ };
+
+ ReviewerAction menu = 1;
+ // In case the card isn't set in a next_card_data intercept function
+ optional int64 current_card_id = 2;
+}
\ No newline at end of file
diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto
index 34b350642..bd06ecd02 100644
--- a/proto/anki/scheduler.proto
+++ b/proto/anki/scheduler.proto
@@ -13,10 +13,12 @@ import "anki/decks.proto";
import "anki/collection.proto";
import "anki/config.proto";
import "anki/deck_config.proto";
+import "anki/card_rendering.proto";
service SchedulerService {
rpc GetQueuedCards(GetQueuedCardsRequest) returns (QueuedCards);
rpc AnswerCard(CardAnswer) returns (collection.OpChanges);
+ rpc NextCardData(NextCardDataRequest) returns (NextCardDataResponse);
rpc SchedTimingToday(generic.Empty) returns (SchedTimingTodayResponse);
rpc StudiedToday(generic.Empty) returns (generic.String);
rpc StudiedTodayMessage(StudiedTodayMessageRequest) returns (generic.String);
@@ -285,6 +287,66 @@ message CardAnswer {
uint32 milliseconds_taken = 6;
}
+message NextCardDataRequest {
+ optional CardAnswer answer = 1;
+}
+
+message NextCardDataResponse {
+ message AnswerButton {
+ CardAnswer.Rating rating = 1;
+ string due = 2;
+ }
+
+ message TypedAnswer {
+ string text = 1;
+ string args = 2;
+ }
+
+ message TimerPreferences {
+ uint32 max_time_ms = 1;
+ bool stop_on_answer = 2;
+ }
+
+ message PartialTemplate {
+ repeated card_rendering.RenderedTemplateNode front = 1;
+ repeated card_rendering.RenderedTemplateNode back = 2;
+ }
+
+ message NextCardData {
+ QueuedCards queue = 1;
+ repeated AnswerButton answer_buttons = 2;
+
+ string front = 3;
+ string back = 4;
+ string css = 5;
+ string body_class = 6;
+ bool autoplay = 7;
+ bool marked = 13;
+ optional TypedAnswer typed_answer = 12;
+ optional TimerPreferences timer = 14;
+ // TODO: Is it worth setting up some sort of "ReviewerPreferences" endpoint
+ // akin to GetGraphPreferences Also should this reviewer setting be moved to
+ // a config bool rather than config.meta
+ bool accept_enter = 15;
+
+ repeated card_rendering.AVTag question_av_tags = 8;
+ repeated card_rendering.AVTag answer_av_tags = 9;
+
+ float autoAdvanceQuestionSeconds = 16;
+ float autoAdvanceAnswerSeconds = 17;
+ bool autoAdvanceWaitForAudio = 20;
+
+ deck_config.DeckConfig.Config.QuestionAction autoAdvanceQuestionAction = 18;
+ deck_config.DeckConfig.Config.AnswerAction autoAdvanceAnswerAction = 19;
+
+ optional PartialTemplate partialTemplate = 11;
+ }
+
+ optional NextCardData next_card = 1;
+ // For media pre-loading. The fields of the note after next_card.
+ string preload = 2;
+}
+
message CustomStudyRequest {
message Cram {
enum CramKind {
diff --git a/qt/aqt/data/web/css/reviewer-bottom.scss b/qt/aqt/data/web/css/reviewer-bottom.scss
index 59098a5fb..8368f4da8 100644
--- a/qt/aqt/data/web/css/reviewer-bottom.scss
+++ b/qt/aqt/data/web/css/reviewer-bottom.scss
@@ -81,4 +81,4 @@ button {
#outer {
border-top-color: color(border-subtle);
}
-}
+}
\ No newline at end of file
diff --git a/qt/aqt/forms/preferences.ui b/qt/aqt/forms/preferences.ui
index 0035e1f42..2a59b5f9d 100644
--- a/qt/aqt/forms/preferences.ui
+++ b/qt/aqt/forms/preferences.ui
@@ -451,6 +451,19 @@
+ -
+
+
+
+ 0
+ 0
+
+
+
+ preferences_use_new_reviewer
+
+
+
-
diff --git a/qt/aqt/main.py b/qt/aqt/main.py
index c707d1b2a..98d4409a3 100644
--- a/qt/aqt/main.py
+++ b/qt/aqt/main.py
@@ -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="{}" {}>{}""".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
##########################################################################
diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py
index bedf23e5b..c7ebef28b 100644
--- a/qt/aqt/mediasrv.py
+++ b/qt/aqt/mediasrv.py
@@ -28,16 +28,33 @@ import aqt
import aqt.main
import aqt.operations
from anki import hooks
-from anki.collection import OpChanges, OpChangesOnly, Progress, SearchNode
+from anki.cards import Card, CardId
+from anki.collection import (
+ OpChanges,
+ OpChangesOnly,
+ Progress,
+ SearchNode,
+)
from anki.decks import UpdateDeckConfigs
+from anki.frontend_pb2 import PlayAVTagsRequest, ReviewerActionRequest
from anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest
+from anki.scheduler_pb2 import NextCardDataRequest, NextCardDataResponse
+from anki.template import (
+ PartiallyRenderedCard,
+ TemplateRenderContext,
+ apply_custom_filters,
+ av_tags_to_native,
+)
from anki.utils import dev_mode
+from aqt import gui_hooks
from aqt.changenotetype import ChangeNotetypeDialog
from aqt.deckoptions import DeckOptionsDialog
from aqt.operations import on_op_finished
from aqt.operations.deck import update_deck_configs as update_deck_configs_op
from aqt.progress import ProgressUpdate
from aqt.qt import *
+from aqt.sound import av_player
+from aqt.theme import ThemeManager
from aqt.utils import aqt_data_path, show_warning, tr
# https://forums.ankiweb.net/t/anki-crash-when-using-a-specific-deck/22266
@@ -45,7 +62,16 @@ waitress.wasyncore._DISCONNECTED = waitress.wasyncore._DISCONNECTED.union({EPROT
logger = logging.getLogger(__name__)
app = flask.Flask(__name__, root_path="/fake")
-flask_cors.CORS(app, resources={r"/*": {"origins": "127.0.0.1"}})
+flask_cors.CORS(
+ app,
+ resources={
+ r"/_anki/js/vendor/mathjax/output/chtml/fonts/woff-v2/.*.woff": {
+ "origins": "*"
+ },
+ r"/media/.*": {"origins": "*"},
+ r"/*": {"origins": "127.0.0.1"},
+ },
+)
@dataclass
@@ -363,6 +389,7 @@ def is_sveltekit_page(path: str) -> bool:
"import-csv",
"import-page",
"image-occlusion",
+ "reviewer",
]
@@ -461,6 +488,7 @@ def _extract_request(
if not aqt.mw.col:
return NotFound(message=f"collection not open, ignore request for {path}")
+ path = path.removeprefix("media/")
path = hooks.media_file_filter(path)
return LocalFileRequest(root=aqt.mw.col.media.dir(), path=path)
@@ -637,6 +665,136 @@ def save_custom_colours() -> bytes:
return b""
+theme_manager = ThemeManager()
+
+
+def next_card_data() -> bytes:
+ raw = aqt.mw.col._backend.next_card_data_raw(request.data)
+ data = NextCardDataResponse.FromString(raw)
+
+ av_player.stop_and_clear_queue()
+ aqt.mw.update_undo_actions()
+
+ if len(data.next_card.queue.cards) == 0:
+ card = None
+ else:
+ backend_card = data.next_card.queue.cards[0].card
+ card = Card(aqt.mw.col, backend_card=backend_card)
+
+ # TODO: Is dealing with gui_hooks in mediasrv like this a good idea?
+ if gui_hooks.reviewer_did_answer_card.count() > 0:
+ req = NextCardDataRequest.FromString(request.data)
+ if req.HasField("answer"):
+ aqt.mw.taskman.run_on_main(
+ lambda: gui_hooks.reviewer_did_answer_card(
+ aqt.mw.reviewer,
+ aqt.mw.col.get_card(CardId(req.answer.card_id)),
+ req.answer.rating + 1, # type: ignore
+ )
+ )
+
+ reviewer = aqt.mw.reviewer
+ # This if statement prevents refreshes from causing the previous card to update.
+ if reviewer.card is None or card is None or card.id != reviewer.card.id:
+ reviewer.previous_card = reviewer.card
+ reviewer.card = card
+
+ def update_card_info():
+ reviewer._previous_card_info.set_card(reviewer.previous_card)
+ reviewer._card_info.set_card(card)
+
+ aqt.mw.taskman.run_on_main(update_card_info)
+
+ if card is None:
+ return data.SerializeToString()
+
+ ctx = TemplateRenderContext.from_existing_card(card, False)
+
+ qside = apply_custom_filters(
+ PartiallyRenderedCard.nodes_from_proto(data.next_card.partialTemplate.front),
+ ctx,
+ None,
+ )
+ aside = apply_custom_filters(
+ PartiallyRenderedCard.nodes_from_proto(data.next_card.partialTemplate.back),
+ ctx,
+ qside,
+ )
+
+ # Dont send the partialy rendered template to the frontend to save bandwidth
+ data.next_card.ClearField("partialTemplate")
+
+ q_avtags = ctx.col()._backend.extract_av_tags(text=qside, question_side=True)
+ a_avtags = ctx.col()._backend.extract_av_tags(text=aside, question_side=False)
+
+ # Assumes the av tags are empty in the original response
+ data.next_card.question_av_tags.extend(q_avtags.av_tags)
+ data.next_card.answer_av_tags.extend(a_avtags.av_tags)
+
+ qside = q_avtags.text
+ aside = a_avtags.text
+
+ qside = aqt.mw.prepare_card_text_for_display(qside)
+ aside = aqt.mw.prepare_card_text_for_display(aside)
+
+ data.next_card.front = qside
+ data.next_card.back = aside
+ # Night mode is handled by the frontend so that it works with the browsers theme if used outside of anki.
+ # Perhaps the OS class should be handled this way too?
+ data.next_card.body_class = theme_manager.body_classes_for_card_ord(card.ord, False)
+ data.next_card.accept_enter = aqt.mw.pm.spacebar_rates_card()
+
+ return data.SerializeToString()
+
+
+def play_avtags():
+ req = PlayAVTagsRequest.FromString(request.data)
+ av_player.play_tags(av_tags_to_native(req.tags))
+ return b""
+
+
+def reviewer_action():
+ reviewer = aqt.mw.reviewer
+ ACTION_ENUM = ReviewerActionRequest.ReviewerAction
+
+ def overview():
+ aqt.mw.moveToState("overview")
+
+ REVIEWER_ACTIONS = {
+ ACTION_ENUM.EditCurrent: aqt.mw.onEditCurrent,
+ ACTION_ENUM.SetDueDate: reviewer.on_set_due,
+ ACTION_ENUM.CardInfo: reviewer.on_card_info,
+ ACTION_ENUM.PreviousCardInfo: reviewer.on_previous_card_info,
+ ACTION_ENUM.CreateCopy: reviewer.on_create_copy,
+ ACTION_ENUM.Forget: reviewer.forget_current_card,
+ ACTION_ENUM.Options: reviewer.onOptions,
+ ACTION_ENUM.Overview: overview,
+ ACTION_ENUM.PauseAudio: reviewer.on_pause_audio,
+ ACTION_ENUM.SeekBackward: reviewer.on_seek_backward,
+ ACTION_ENUM.SeekForward: reviewer.on_seek_forward,
+ ACTION_ENUM.RecordVoice: reviewer.onRecordVoice,
+ ACTION_ENUM.ReplayRecorded: reviewer.onReplayRecorded,
+ }
+
+ req = ReviewerActionRequest.FromString(request.data)
+ aqt.mw.taskman.run_on_main(REVIEWER_ACTIONS[req.menu])
+ return b""
+
+
+def undo_redo(action: str):
+ resp = raw_backend_request(action)()
+ aqt.mw.update_undo_actions()
+ return resp
+
+
+def undo():
+ return undo_redo("undo")
+
+
+def redo():
+ return undo_redo("redo")
+
+
post_handler_list = [
congrats_info,
get_deck_configs_for_update,
@@ -653,6 +811,11 @@ post_handler_list = [
deck_options_require_close,
deck_options_ready,
save_custom_colours,
+ next_card_data,
+ play_avtags,
+ reviewer_action,
+ undo,
+ redo,
]
@@ -660,6 +823,9 @@ exposed_backend_list = [
# CollectionService
"latest_progress",
"get_custom_colours",
+ "set_config_json",
+ "get_config_json",
+ "get_undo_status",
# DeckService
"get_deck_names",
# I18nService
@@ -670,6 +836,9 @@ exposed_backend_list = [
# NotesService
"get_field_names",
"get_note",
+ "remove_notes",
+ "add_note_tags",
+ "remove_note_tags",
# NotetypesService
"get_notetype_names",
"get_change_notetype_info",
@@ -695,9 +864,13 @@ exposed_backend_list = [
"get_optimal_retention_parameters",
"simulate_fsrs_review",
"simulate_fsrs_workload",
+ "bury_or_suspend_cards",
# DeckConfigService
"get_ignored_before_count",
"get_retention_workload",
+ # CardsService
+ "set_flag",
+ "compare_answer",
]
diff --git a/qt/aqt/preferences.py b/qt/aqt/preferences.py
index 939dd8c2c..b20763877 100644
--- a/qt/aqt/preferences.py
+++ b/qt/aqt/preferences.py
@@ -138,6 +138,7 @@ class Preferences(QDialog):
form.showProgress.setChecked(reviewing.show_remaining_due_counts)
form.showPlayButtons.setChecked(not reviewing.hide_audio_play_buttons)
form.interrupt_audio.setChecked(reviewing.interrupt_audio_when_answering)
+ form.new_reviewer.setChecked(reviewing.new_reviewer)
editing = self.prefs.editing
form.useCurrent.setCurrentIndex(
@@ -173,6 +174,7 @@ class Preferences(QDialog):
reviewing.time_limit_secs = form.timeLimit.value() * 60
reviewing.hide_audio_play_buttons = not self.form.showPlayButtons.isChecked()
reviewing.interrupt_audio_when_answering = self.form.interrupt_audio.isChecked()
+ reviewing.new_reviewer = form.new_reviewer.isChecked()
editing = self.prefs.editing
editing.adding_defaults_to_current_deck = not form.useCurrent.currentIndex()
diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py
index 6d68f9e3a..974ce244c 100644
--- a/qt/aqt/reviewer.py
+++ b/qt/aqt/reviewer.py
@@ -172,9 +172,7 @@ class Reviewer:
gui_hooks.av_player_did_end_playing.append(self._on_av_player_did_end_playing)
def show(self) -> None:
- if self.mw.col.sched_ver() == 1 or not self.mw.col.v3_scheduler():
- self.mw.moveToState("deckBrowser")
- show_warning(tr.scheduling_update_required().replace("V2", "v3"))
+ if not self._scheduler_version_check():
return
self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore
self.web.set_bridge_command(self._linkHandler, self)
@@ -184,6 +182,13 @@ class Reviewer:
self._refresh_needed = RefreshNeeded.QUEUES
self.refresh_if_needed()
+ def _scheduler_version_check(self):
+ if self.mw.col.sched_ver() == 1 or not self.mw.col.v3_scheduler():
+ self.mw.moveToState("deckBrowser")
+ show_warning(tr.scheduling_update_required().replace("V2", "v3"))
+ return False
+ return True
+
# this is only used by add-ons
def lastCard(self) -> Card | None:
if self._answeredIds:
@@ -444,7 +449,6 @@ class Reviewer:
tooltip(tr.studying_question_time_elapsed())
def autoplay(self, card: Card) -> bool:
- print("use card.autoplay() instead of reviewer.autoplay(card)")
return card.autoplay()
def _update_flag_icon(self) -> None:
@@ -1233,6 +1237,36 @@ timerStopped = false;
setFlag = set_flag_on_current_card
+class SvelteReviewer(Reviewer):
+ def refresh_if_needed(self):
+ if self._refresh_needed:
+ self.mw.fade_in_webview()
+ self.web.eval("if (anki) {anki.changeReceived()}")
+ self._refresh_needed = None
+
+ def show(self) -> None:
+ if not self._scheduler_version_check():
+ return
+ self._initWeb()
+ # Prevents the shortcuts selecting the toolbar buttons for the next time enter is pressed
+ self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore
+
+ def _linkHandler(self, url: str) -> None:
+ pass
+
+ def _initWeb(self) -> None:
+ self._reps = 0
+ # hide the bottom bar
+ self.bottom.web.setHtml("")
+ # main window
+ self.web.load_sveltekit_page("reviewer")
+ # block default drag & drop behavior while allowing drop events to be received by JS handlers
+ self.web.allow_drops = True
+
+ def _shortcutKeys(self) -> Sequence[tuple[str, Callable] | tuple[Qt.Key, Callable]]:
+ return []
+
+
# if the last element is a comment, then the RUN_STATE_MUTATION code
# breaks due to the comment wrongly commenting out python code.
# To prevent this we put the js code on a separate line
diff --git a/qt/aqt/toolbar.py b/qt/aqt/toolbar.py
index be547b5ba..4517453ac 100644
--- a/qt/aqt/toolbar.py
+++ b/qt/aqt/toolbar.py
@@ -211,7 +211,11 @@ class BottomWebView(ToolbarWebView):
def animate_height(self, height: int) -> None:
self.web_height = height
- if self.mw.pm.reduce_motion() or height == self.height():
+ if (
+ self.mw.pm.reduce_motion()
+ or self.mw.col.conf.get("newReviewer")
+ or height == self.height()
+ ):
self.setFixedHeight(height)
else:
# Collapse/Expand animation
@@ -426,6 +430,7 @@ class Toolbar:
######################################################################
def _linkHandler(self, link: str) -> bool:
+ self.mw.web.setFocus()
if link in self.link_handlers:
self.link_handlers[link]()
return False
@@ -457,7 +462,7 @@ class Toolbar:
######################################################################
_body = """
-