From e5685254c67da2e5bd1822437992706d2d01eb7f Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 27 Aug 2020 21:46:34 +1000 Subject: [PATCH] reimplement congrats screen in Rust+Typescript --- proto/backend.proto | 13 ++- pylib/anki/rsbackend.py | 3 + pylib/anki/schedv2.py | 115 +++-------------------- pylib/tests/test_schedv1.py | 21 ----- pylib/tests/test_schedv2.py | 21 ----- qt/.gitignore | 1 + qt/aqt/mediasrv.py | 54 ++++++----- qt/aqt/overview.py | 27 +++++- qt/aqt/stats.py | 1 + qt/aqt/webview.py | 13 ++- rslib/ftl/scheduling.ftl | 9 +- rslib/src/backend/mod.rs | 13 +-- rslib/src/sched/congrats.rs | 38 ++++++++ rslib/src/sched/mod.rs | 1 + rslib/src/sched/timespan.rs | 27 +----- rslib/src/storage/card/congrats.sql | 22 +++++ rslib/src/storage/card/mod.rs | 33 ++++++- rslib/src/storage/deck/mod.rs | 18 ++++ rslib/src/storage/deck/update_active.sql | 8 ++ rspy/src/lib.rs | 2 +- ts/src/bridgecommand.ts | 7 ++ ts/src/html/congrats.html | 13 +++ ts/src/html/graphs.html | 6 +- ts/src/nightmode.ts | 12 +++ ts/src/postrequest.ts | 17 ++++ ts/src/sched/CongratsPage.svelte | 67 +++++++++++++ ts/src/sched/congrats-bootstrap.ts | 17 ++++ ts/src/sched/congrats.ts | 36 +++++++ ts/src/scss/core.scss | 1 + ts/src/stats/graphs-bootstrap.ts | 5 +- ts/src/stats/graphs.ts | 24 +---- ts/src/time.ts | 6 +- ts/webpack.config.js | 6 ++ 33 files changed, 412 insertions(+), 245 deletions(-) create mode 100644 rslib/src/sched/congrats.rs create mode 100644 rslib/src/storage/card/congrats.sql create mode 100644 rslib/src/storage/deck/update_active.sql create mode 100644 ts/src/bridgecommand.ts create mode 100644 ts/src/html/congrats.html create mode 100644 ts/src/nightmode.ts create mode 100644 ts/src/postrequest.ts create mode 100644 ts/src/sched/CongratsPage.svelte create mode 100644 ts/src/sched/congrats-bootstrap.ts create mode 100644 ts/src/sched/congrats.ts diff --git a/proto/backend.proto b/proto/backend.proto index 01346b02d..7a3fdc7e9 100644 --- a/proto/backend.proto +++ b/proto/backend.proto @@ -92,10 +92,10 @@ service BackendService { rpc SetLocalMinutesWest (Int32) returns (Empty); rpc SchedTimingToday (Empty) returns (SchedTimingTodayOut); rpc StudiedToday (StudiedTodayIn) returns (String); - rpc CongratsLearnMessage (CongratsLearnMessageIn) returns (String); rpc UpdateStats (UpdateStatsIn) returns (Empty); rpc ExtendLimits (ExtendLimitsIn) returns (Empty); rpc CountsForDeckToday (DeckID) returns (CountsForDeckTodayOut); + rpc CongratsInfo (Empty) returns (CongratsInfoOut); // stats @@ -1016,3 +1016,14 @@ message RevlogEntry { uint32 taken_millis = 8; ReviewKind review_kind = 9; } + +message CongratsInfoOut { + uint32 learn_remaining = 1; + uint32 secs_until_next_learn = 2; + bool review_remaining = 3; + bool new_remaining = 4; + bool have_sched_buried = 5; + bool have_user_buried = 6; + bool is_filtered_deck = 7; + bool bridge_commands_supported = 8; +} diff --git a/pylib/anki/rsbackend.py b/pylib/anki/rsbackend.py index 58ecb9669..ff817c903 100644 --- a/pylib/anki/rsbackend.py +++ b/pylib/anki/rsbackend.py @@ -9,6 +9,9 @@ Please do not access methods on the backend directly - they may be changed or removed at any time. Instead, please use the methods on the collection instead. Eg, don't use col.backend.all_deck_config(), instead use col.decks.all_config() + +If you need to access a backend method that is not currently accessible +via the collection, please send through a pull request that adds a method. """ from __future__ import annotations diff --git a/pylib/anki/schedv2.py b/pylib/anki/schedv2.py index 162efbb24..6eb23c81d 100644 --- a/pylib/anki/schedv2.py +++ b/pylib/anki/schedv2.py @@ -10,6 +10,7 @@ from heapq import * from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, Union import anki # pylint: disable=unused-import +import anki.backend_pb2 as pb from anki import hooks from anki.cards import Card from anki.consts import * @@ -1281,118 +1282,26 @@ where id = ? # Deck finished state ########################################################################## - def finishedMsg(self) -> str: - return ( - "" - + _("Congratulations! You have finished this deck for now.") - + "

" - + self._nextDueMsg() - ) + def congratulations_info(self) -> pb.CongratsInfoOut: + return self.col.backend.congrats_info() - def next_learn_msg(self) -> str: - dids = self._deckLimit() - (next, remaining) = self.col.db.first( - f""" -select min(due), count(*) -from cards where did in {dids} and queue = {QUEUE_TYPE_LRN} -""" - ) - next = next or 0 - remaining = remaining or 0 - if next and next < self.dayCutoff: - next -= intTime() + self.col.conf["collapseTime"] - return self.col.backend.congrats_learn_message( - next_due=abs(next), remaining=remaining - ) - else: - return "" + def finishedMsg(self) -> str: + print("finishedMsg() is obsolete") + return "" def _nextDueMsg(self) -> str: - line = [] - - learn_msg = self.next_learn_msg() - if learn_msg: - line.append(learn_msg) - - # the new line replacements are so we don't break translations - # in a point release - if self.revDue(): - line.append( - _( - """\ -Today's review limit has been reached, but there are still cards -waiting to be reviewed. For optimum memory, consider increasing -the daily limit in the options.""" - ).replace("\n", " ") - ) - if self.newDue(): - line.append( - _( - """\ -There are more new cards available, but the daily limit has been -reached. You can increase the limit in the options, but please -bear in mind that the more new cards you introduce, the higher -your short-term review workload will become.""" - ).replace("\n", " ") - ) - if self.haveBuried(): - if self.haveCustomStudy: - now = " " + _("To see them now, click the Unbury button below.") - else: - now = "" - line.append( - _( - """\ -Some related or buried cards were delayed until a later session.""" - ) - + now - ) - if self.haveCustomStudy and not self.col.decks.current()["dyn"]: - line.append( - _( - """\ -To study outside of the normal schedule, click the Custom Study button below.""" - ) - ) - return "

".join(line) - - def revDue(self) -> Optional[int]: - "True if there are any rev cards due." - return self.col.db.scalar( - ( - f"select 1 from cards where did in %s and queue = {QUEUE_TYPE_REV} " - "and due <= ? limit 1" - ) - % self._deckLimit(), - self.today, - ) - - def newDue(self) -> Optional[int]: - "True if there are any new cards due." - return self.col.db.scalar( - ( - f"select 1 from cards where did in %s and queue = {QUEUE_TYPE_NEW} " - "limit 1" - ) - % self._deckLimit() - ) + print("_nextDueMsg() is obsolete") + return "" def haveBuriedSiblings(self) -> bool: - cnt = self.col.db.scalar( - f"select 1 from cards where queue = {QUEUE_TYPE_SIBLING_BURIED} and did in %s limit 1" - % self._deckLimit() - ) - return not not cnt + return self.congratulations_info().have_sched_buried def haveManuallyBuried(self) -> bool: - cnt = self.col.db.scalar( - f"select 1 from cards where queue = {QUEUE_TYPE_MANUALLY_BURIED} and did in %s limit 1" - % self._deckLimit() - ) - return not not cnt + return self.congratulations_info().have_user_buried def haveBuried(self) -> bool: - return self.haveManuallyBuried() or self.haveBuriedSiblings() + info = self.congratulations_info() + return info.have_sched_buried or info.have_user_buried # Next time reports ########################################################################## diff --git a/pylib/tests/test_schedv1.py b/pylib/tests/test_schedv1.py index a72e5da90..dc33a86ea 100644 --- a/pylib/tests/test_schedv1.py +++ b/pylib/tests/test_schedv1.py @@ -433,27 +433,6 @@ def test_overdue_lapse(): assert col.sched.counts() == (0, 0, 1) -def test_finished(): - col = getEmptyCol() - # nothing due - assert "Congratulations" in col.sched.finishedMsg() - assert "limit" not in col.sched.finishedMsg() - note = col.newNote() - note["Front"] = "one" - note["Back"] = "two" - col.addNote(note) - # have a new card - assert "new cards available" in col.sched.finishedMsg() - # turn it into a review - col.reset() - c = note.cards()[0] - c.startTimer() - col.sched.answerCard(c, 3) - # nothing should be due tomorrow, as it's due in a week - assert "Congratulations" in col.sched.finishedMsg() - assert "limit" not in col.sched.finishedMsg() - - def test_nextIvl(): col = getEmptyCol() note = col.newNote() diff --git a/pylib/tests/test_schedv2.py b/pylib/tests/test_schedv2.py index 51c2c1b3f..2c4d9c3f2 100644 --- a/pylib/tests/test_schedv2.py +++ b/pylib/tests/test_schedv2.py @@ -525,27 +525,6 @@ def test_overdue_lapse(): assert col.sched.counts() == (0, 0, 1) -def test_finished(): - col = getEmptyCol() - # nothing due - assert "Congratulations" in col.sched.finishedMsg() - assert "limit" not in col.sched.finishedMsg() - note = col.newNote() - note["Front"] = "one" - note["Back"] = "two" - col.addNote(note) - # have a new card - assert "new cards available" in col.sched.finishedMsg() - # turn it into a review - col.reset() - c = note.cards()[0] - c.startTimer() - col.sched.answerCard(c, 3) - # nothing should be due tomorrow, as it's due in a week - assert "Congratulations" in col.sched.finishedMsg() - assert "limit" not in col.sched.finishedMsg() - - def test_nextIvl(): col = getEmptyCol() note = col.newNote() diff --git a/qt/.gitignore b/qt/.gitignore index 43f4d25ac..b38578942 100644 --- a/qt/.gitignore +++ b/qt/.gitignore @@ -21,6 +21,7 @@ aqt_data/web/reviewer.js aqt_data/web/webview.js aqt_data/web/toolbar.js aqt_data/web/graphs* +aqt_data/web/congrats* aqt_data/web/*.css dist aqt.egg-info diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index d7294f018..880455fc2 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -15,11 +15,10 @@ from http import HTTPStatus import flask import flask_cors # type: ignore -from flask import request +from flask import Response, request from waitress.server import create_server import aqt -from anki.collection import Collection from anki.rsbackend import from_json_bytes from anki.utils import devMode from aqt.qt import * @@ -127,24 +126,7 @@ def allroutes(pathin): try: if flask.request.method == "POST": - if not aqt.mw.col: - print(f"collection not open, ignore request for {path}") - return flask.make_response("Collection not open", HTTPStatus.NOT_FOUND) - - if path == "graphData": - body = request.data - data = graph_data(aqt.mw.col, **from_json_bytes(body)) - elif path == "i18nResources": - data = aqt.mw.col.backend.i18n_resources() - else: - return flask.make_response( - "Post request to '%s - %s' is a security leak!" % (directory, path), - HTTPStatus.FORBIDDEN, - ) - - response = flask.make_response(data) - response.headers["Content-Type"] = "application/binary" - return response + return handle_post(path) if fullpath.endswith(".css"): # some users may have invalid mime type in the Windows registry @@ -219,5 +201,33 @@ def _redirectWebExports(path): return aqt.mw.col.media.dir(), path -def graph_data(col: Collection, search: str, days: int) -> bytes: - return col.backend.graphs(search=search, days=days) +def graph_data() -> bytes: + args = from_json_bytes(request.data) + return aqt.mw.col.backend.graphs(search=args["search"], days=args["days"]) + + +def congrats_info() -> bytes: + info = aqt.mw.col.backend.congrats_info() + return info.SerializeToString() + + +post_handlers = dict( + graphData=graph_data, + # pylint: disable=unnecessary-lambda + i18nResources=lambda: aqt.mw.col.backend.i18n_resources(), + congratsInfo=congrats_info, +) + + +def handle_post(path: str) -> Response: + if not aqt.mw.col: + print(f"collection not open, ignore request for {path}") + return flask.make_response("Collection not open", HTTPStatus.NOT_FOUND) + + if path in post_handlers: + data = post_handlers[path]() + response = flask.make_response(data) + response.headers["Content-Type"] = "application/binary" + return response + else: + return flask.make_response(f"Unhandled post to {path}", HTTPStatus.FORBIDDEN,) diff --git a/qt/aqt/overview.py b/qt/aqt/overview.py index 52a7008c1..4881d3d6c 100644 --- a/qt/aqt/overview.py +++ b/qt/aqt/overview.py @@ -5,11 +5,14 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Optional import aqt from anki.lang import _ from aqt import gui_hooks +from aqt.qt import QUrl from aqt.sound import av_player +from aqt.theme import theme_manager from aqt.toolbar import BottomBar from aqt.utils import askUserDialog, openLink, shortcut, tooltip @@ -84,7 +87,7 @@ class Overview: self.mw.moveToState("deckBrowser") elif url == "review": openLink(aqt.appShared + "info/%s?v=%s" % (self.sid, self.sidVer)) - elif url == "studymore": + elif url == "studymore" or url == "customStudy": self.onStudyMore() elif url == "unbury": self.onUnbury() @@ -161,6 +164,11 @@ class Overview: shareLink = 'Reviews and Updates' else: shareLink = "" + table_text = self._table() + if not table_text: + # deck is finished + self._show_finished_screen() + return content = OverviewContent( deck=deck["name"], shareLink=shareLink, @@ -175,6 +183,16 @@ class Overview: context=self, ) + def _show_finished_screen(self): + self.web.set_open_links_externally(False) + if theme_manager.night_mode: + extra = "#night" + else: + extra = "" + self.web.hide_while_preserving_layout() + self.web.load(QUrl(f"{self.mw.serverURL()}_anki/congrats.html" + extra)) + self.web.inject_dynamic_style_and_show() + def _desc(self, deck): if deck["dyn"]: desc = _( @@ -201,14 +219,13 @@ to their original deck.""" dyn = "" return '

%s
' % (dyn, desc) - def _table(self): + def _table(self) -> Optional[str]: + "Return table text if deck is not finished." counts = list(self.mw.col.sched.counts()) finished = not sum(counts) but = self.mw.button if finished: - return '
%s
' % ( - self.mw.col.sched.finishedMsg() - ) + return None else: return """ diff --git a/qt/aqt/stats.py b/qt/aqt/stats.py index 772331bde..cdf90589d 100644 --- a/qt/aqt/stats.py +++ b/qt/aqt/stats.py @@ -95,6 +95,7 @@ class NewDeckStats(QDialog): self.form.web.load(QUrl(f"{self.mw.serverURL()}_anki/graphs.html" + extra)) self.form.web.inject_dynamic_style_and_show() + class DeckStats(QDialog): """Legacy deck stats, used by some add-ons.""" diff --git a/qt/aqt/webview.py b/qt/aqt/webview.py index 24ae80f04..665cf7c52 100644 --- a/qt/aqt/webview.py +++ b/qt/aqt/webview.py @@ -54,9 +54,9 @@ class AnkiWebPage(QWebEnginePage): script.setSourceCode( jstext + """ - var pycmd; + var pycmd, bridgeCommand; new QWebChannel(qt.webChannelTransport, function(channel) { - pycmd = function (arg, cb) { + bridgeCommand = pycmd = function (arg, cb) { var resultCB = function (res) { // pass result back to user-provided callback if (cb) { @@ -400,7 +400,7 @@ div[contenteditable="true"]:focus { } zoom = self.zoomFactor() - background = self._getWindowColor().name() + background = self._getWindowColor().name() if is_rtl(anki.lang.currentLang): lang_dir = "rtl" @@ -598,8 +598,11 @@ body {{ zoom: {zoom}; background: {background}; direction: {lang_dir}; {font} }} def inject_dynamic_style_and_show(self): "Add dynamic styling, and reveal." css = self.standard_css() - self.evalWithCallback(f""" + self.evalWithCallback( + f""" const style = document.createElement('style'); style.innerHTML = `{css}`; document.head.appendChild(style); -""", lambda arg: self.show()) +""", + lambda arg: self.show(), + ) diff --git a/rslib/ftl/scheduling.ftl b/rslib/ftl/scheduling.ftl index 5f1dc9236..e639dfa9a 100644 --- a/rslib/ftl/scheduling.ftl +++ b/rslib/ftl/scheduling.ftl @@ -82,4 +82,11 @@ scheduling-today-new-limit-reached = reached. You can increase the limit in the options, but please bear in mind that the more new cards you introduce, the higher your short-term review workload will become. -scheduling-buried-cards-were-delayed = Some related or buried cards were delayed until a later session. +scheduling-buried-cards-found = One or more cards were buried, and will be shown tomorrow. You can { $unburyThem } if you wish to see them immediately. +# used in scheduling-buried-cards-found +# "... you can unbury them if you wish to see..." +scheduling-unbury-them = unbury them +scheduling-how-to-custom-study = If you wish to study outside of the regular schedule, you can use the { $customStudy } feature. +# used in scheduling-how-to-custom-study +# "... you can use the custom study feature." +scheduling-custom-study = custom study diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 6dab3ee07..89760083b 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -31,7 +31,7 @@ use crate::{ RenderCardOutput, }, sched::cutoff::local_minutes_west_for_stamp, - sched::timespan::{answer_button_time, learning_congrats, studied_today, time_span}, + sched::timespan::{answer_button_time, studied_today, time_span}, search::SortMode, sync::{ get_remote_sync_meta, sync_abort, sync_login, FullSyncProgress, NormalSyncProgress, @@ -464,13 +464,6 @@ impl BackendService for Backend { Ok(studied_today(input.cards as usize, input.seconds as f32, &self.i18n).into()) } - fn congrats_learn_message( - &mut self, - input: pb::CongratsLearnMessageIn, - ) -> BackendResult { - Ok(learning_congrats(input.remaining as usize, input.next_due, &self.i18n).into()) - } - fn update_stats(&mut self, input: pb::UpdateStatsIn) -> BackendResult { self.with_col(|col| { col.transact(None, |col| { @@ -505,6 +498,10 @@ impl BackendService for Backend { self.with_col(|col| col.counts_for_deck_today(input.did.into())) } + fn congrats_info(&mut self, _input: Empty) -> BackendResult { + self.with_col(|col| col.congrats_info()) + } + // statistics //----------------------------------------------- diff --git a/rslib/src/sched/congrats.rs b/rslib/src/sched/congrats.rs new file mode 100644 index 000000000..e1aa62327 --- /dev/null +++ b/rslib/src/sched/congrats.rs @@ -0,0 +1,38 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::backend_proto as pb; +use crate::prelude::*; + +pub(crate) struct CongratsInfo { + pub learn_count: u32, + pub next_learn_due: u32, + pub review_remaining: bool, + pub new_remaining: bool, + pub have_sched_buried: bool, + pub have_user_buried: bool, +} + +impl Collection { + pub fn congrats_info(&mut self) -> Result { + let did = self.get_current_deck_id(); + let deck = self.get_deck(did)?.ok_or(AnkiError::NotFound)?; + let today = self.timing_today()?.days_elapsed; + let info = self.storage.congrats_info(&deck, today)?; + let is_filtered_deck = deck.is_filtered(); + let secs_until_next_learn = ((info.next_learn_due as i64) + - self.learn_ahead_secs() as i64 + - TimestampSecs::now().0) + .max(0) as u32; + Ok(pb::CongratsInfoOut { + learn_remaining: info.learn_count, + review_remaining: info.review_remaining, + new_remaining: info.new_remaining, + have_sched_buried: info.have_sched_buried, + have_user_buried: info.have_user_buried, + is_filtered_deck, + secs_until_next_learn, + bridge_commands_supported: true, + }) + } +} diff --git a/rslib/src/sched/mod.rs b/rslib/src/sched/mod.rs index 70e745632..ba7ddf143 100644 --- a/rslib/src/sched/mod.rs +++ b/rslib/src/sched/mod.rs @@ -5,6 +5,7 @@ use crate::{ collection::Collection, config::SchedulerVersion, err::Result, timestamp::TimestampSecs, }; +pub(crate) mod congrats; pub mod cutoff; pub mod timespan; diff --git a/rslib/src/sched/timespan.rs b/rslib/src/sched/timespan.rs index ded982065..ff74e186e 100644 --- a/rslib/src/sched/timespan.rs +++ b/rslib/src/sched/timespan.rs @@ -56,25 +56,6 @@ pub fn studied_today(cards: usize, secs: f32, i18n: &I18n) -> String { i18n.trn(TR::StatisticsStudiedToday, args) } -// fixme: this doesn't belong here -pub fn learning_congrats(remaining: usize, next_due: f32, i18n: &I18n) -> String { - // next learning card not due (/ until tomorrow)? - if next_due == 0.0 || next_due >= 86_400.0 { - return "".to_string(); - } - - let span = Timespan::from_secs(next_due).natural_span(); - let amount = span.as_unit().round(); - let unit = span.unit().as_str(); - let next_args = tr_args!["amount" => amount, "unit" => unit]; - let remaining_args = tr_args!["remaining" => remaining]; - format!( - "{} {}", - i18n.trn(TR::SchedulingNextLearnDue, next_args), - i18n.trn(TR::SchedulingLearnRemaining, remaining_args) - ) -} - const SECOND: f32 = 1.0; const MINUTE: f32 = 60.0 * SECOND; const HOUR: f32 = 60.0 * MINUTE; @@ -177,9 +158,7 @@ impl Timespan { mod test { use crate::i18n::I18n; use crate::log; - use crate::sched::timespan::{ - answer_button_time, learning_congrats, studied_today, time_span, MONTH, - }; + use crate::sched::timespan::{answer_button_time, studied_today, time_span, MONTH}; #[test] fn answer_buttons() { @@ -211,9 +190,5 @@ mod test { &studied_today(3, 13.0, &i18n).replace("\n", " "), "Studied 3 cards in 13 seconds today (4.33s/card)" ); - assert_eq!( - &learning_congrats(3, 3700.0, &i18n).replace("\n", " "), - "The next learning card will be ready in 1 hour. There are 3 learning cards due later today." - ); } } diff --git a/rslib/src/storage/card/congrats.sql b/rslib/src/storage/card/congrats.sql new file mode 100644 index 000000000..28d069cd6 --- /dev/null +++ b/rslib/src/storage/card/congrats.sql @@ -0,0 +1,22 @@ +select sum( + queue in (:review_queue, :day_learn_queue) + and due <= :today + ) as review_count, + sum(queue = :new_queue) as new_count, + sum(queue = :sched_buried_queue) as sched_buried, + sum(queue = :user_buried_queue) as user_buried, + sum(queue = :learn_queue) as learn_count, + coalesce( + min( + case + when queue = :learn_queue then due + else null + end + ), + 0 + ) as first_learn_due +from cards +where did in ( + select id + from active_decks + ) \ No newline at end of file diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index 232ed1792..8fe9791af 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -3,14 +3,16 @@ use crate::{ card::{Card, CardID, CardQueue, CardType}, - decks::DeckID, + decks::{Deck, DeckID}, err::Result, notes::NoteID, + sched::congrats::CongratsInfo, timestamp::{TimestampMillis, TimestampSecs}, types::Usn, }; use rusqlite::params; use rusqlite::{ + named_params, types::{FromSql, FromSqlError, ValueRef}, OptionalExtension, Row, NO_PARAMS, }; @@ -277,6 +279,35 @@ impl super::SqliteStorage { Ok(()) } + + pub(crate) fn congrats_info(&self, current: &Deck, today: u32) -> Result { + self.update_active_decks(current)?; + self.db + .prepare(include_str!("congrats.sql"))? + .query_and_then_named( + named_params! { + ":review_queue": CardQueue::Review as i8, + ":day_learn_queue": CardQueue::DayLearn as i8, + ":new_queue": CardQueue::New as i8, + ":user_buried_queue": CardQueue::UserBuried as i8, + ":sched_buried_queue": CardQueue::SchedBuried as i8, + ":learn_queue": CardQueue::Learn as i8, + ":today": today, + }, + |row| { + Ok(CongratsInfo { + review_remaining: row.get::<_, u32>(0)? > 0, + new_remaining: row.get::<_, u32>(1)? > 0, + have_sched_buried: row.get::<_, u32>(2)? > 0, + have_user_buried: row.get::<_, u32>(3)? > 0, + learn_count: row.get(4)?, + next_learn_due: row.get(5)?, + }) + }, + )? + .next() + .unwrap() + } } #[cfg(test)] diff --git a/rslib/src/storage/deck/mod.rs b/rslib/src/storage/deck/mod.rs index 3408dc42a..00ea9c9fe 100644 --- a/rslib/src/storage/deck/mod.rs +++ b/rslib/src/storage/deck/mod.rs @@ -242,6 +242,24 @@ impl SqliteStorage { Ok(()) } + /// Write active decks into temporary active_decks table. + pub(crate) fn update_active_decks(&self, current: &Deck) -> Result<()> { + self.db.execute_batch(concat!( + "drop table if exists temp.active_decks;", + "create temporary table active_decks (id integer primary key not null);" + ))?; + + let top = ¤t.name; + let prefix_start = &format!("{}\x1f", top); + let prefix_end = &format!("{}\x20", top); + + self.db + .prepare_cached(include_str!("update_active.sql"))? + .execute(&[top, prefix_start, prefix_end])?; + + Ok(()) + } + // Upgrading/downgrading/legacy pub(super) fn add_default_deck(&self, i18n: &I18n) -> Result<()> { diff --git a/rslib/src/storage/deck/update_active.sql b/rslib/src/storage/deck/update_active.sql new file mode 100644 index 000000000..54f49bf7f --- /dev/null +++ b/rslib/src/storage/deck/update_active.sql @@ -0,0 +1,8 @@ +insert into active_decks +select id +from decks +where name = ? + or ( + name >= ? + and name < ? + ) \ No newline at end of file diff --git a/rspy/src/lib.rs b/rspy/src/lib.rs index bda030380..b64f4e5d7 100644 --- a/rspy/src/lib.rs +++ b/rspy/src/lib.rs @@ -84,7 +84,6 @@ fn want_release_gil(method: u32) -> bool { BackendMethod::FindAndReplace => true, BackendMethod::SetLocalMinutesWest => false, BackendMethod::StudiedToday => false, - BackendMethod::CongratsLearnMessage => false, BackendMethod::AddMediaFile => true, BackendMethod::EmptyTrash => true, BackendMethod::RestoreTrash => true, @@ -120,6 +119,7 @@ fn want_release_gil(method: u32) -> bool { BackendMethod::CardStats => true, BackendMethod::Graphs => true, BackendMethod::I18nResources => false, + BackendMethod::CongratsInfo => true, } } else { false diff --git a/ts/src/bridgecommand.ts b/ts/src/bridgecommand.ts new file mode 100644 index 000000000..b7a0f721b --- /dev/null +++ b/ts/src/bridgecommand.ts @@ -0,0 +1,7 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +/// HTML tag pointing to a bridge command. +export function bridgeLink(command: string, label: string): string { + return `${label}`; +} diff --git a/ts/src/html/congrats.html b/ts/src/html/congrats.html new file mode 100644 index 000000000..62cfbab31 --- /dev/null +++ b/ts/src/html/congrats.html @@ -0,0 +1,13 @@ + + + + + + + +
+ + + diff --git a/ts/src/html/graphs.html b/ts/src/html/graphs.html index d2a041d52..9ed95ba15 100644 --- a/ts/src/html/graphs.html +++ b/ts/src/html/graphs.html @@ -8,10 +8,6 @@
diff --git a/ts/src/nightmode.ts b/ts/src/nightmode.ts new file mode 100644 index 000000000..65ff6a911 --- /dev/null +++ b/ts/src/nightmode.ts @@ -0,0 +1,12 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +/// Add night-mode class to body if hash location is #night, and return +/// true if added. +export function checkNightMode(): boolean { + const nightMode = window.location.hash == "#night"; + if (nightMode) { + document.documentElement.className = "night-mode"; + } + return nightMode; +} diff --git a/ts/src/postrequest.ts b/ts/src/postrequest.ts new file mode 100644 index 000000000..100fe668f --- /dev/null +++ b/ts/src/postrequest.ts @@ -0,0 +1,17 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +export async function postRequest(path: string, body: string): Promise { + const resp = await fetch(path, { + method: "POST", + body, + }); + if (!resp.ok) { + throw Error(`unexpected reply: ${resp.statusText}`); + } + // get returned bytes + const respBlob = await resp.blob(); + const respBuf = await new Response(respBlob).arrayBuffer(); + const bytes = new Uint8Array(respBuf); + return bytes; +} diff --git a/ts/src/sched/CongratsPage.svelte b/ts/src/sched/CongratsPage.svelte new file mode 100644 index 000000000..273d0af57 --- /dev/null +++ b/ts/src/sched/CongratsPage.svelte @@ -0,0 +1,67 @@ + + + + + + +
+
+

{congrats}

+ +

{nextLearnMsg}

+ + {#if info.reviewRemaining} +

{today_reviews}

+ {/if} + + {#if info.newRemaining} +

{today_new}

+ {/if} + + {#if info.bridgeCommandsSupported} + {#if info.haveSchedBuried || info.haveUserBuried} +

+ {@html buriedMsg} +

+ {/if} + +

+ {@html customStudyMsg} +

+ {/if} +
+
diff --git a/ts/src/sched/congrats-bootstrap.ts b/ts/src/sched/congrats-bootstrap.ts new file mode 100644 index 000000000..04e7d8aef --- /dev/null +++ b/ts/src/sched/congrats-bootstrap.ts @@ -0,0 +1,17 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import { setupI18n } from "../i18n"; +import CongratsPage from "./CongratsPage.svelte"; +import { getCongratsInfo } from "./congrats"; +import { checkNightMode } from "../nightmode"; + +export async function congrats(target: HTMLDivElement): Promise { + checkNightMode(); + const i18n = await setupI18n(); + const info = await getCongratsInfo(); + new CongratsPage({ + target, + props: { info, i18n }, + }); +} diff --git a/ts/src/sched/congrats.ts b/ts/src/sched/congrats.ts new file mode 100644 index 000000000..929409503 --- /dev/null +++ b/ts/src/sched/congrats.ts @@ -0,0 +1,36 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import pb from "../backend/proto"; +import { postRequest } from "../postrequest"; +import { naturalUnit, unitAmount, unitName } from "../time"; +import { I18n } from "../i18n"; + +export async function getCongratsInfo(): Promise { + return pb.BackendProto.CongratsInfoOut.decode( + await postRequest("/_anki/congratsInfo", "") + ); +} + +export function buildNextLearnMsg( + info: pb.BackendProto.CongratsInfoOut, + i18n: I18n +): string { + const secsUntil = info.secsUntilNextLearn; + // next learning card not due (/ until tomorrow)? + if (secsUntil == 0 || secsUntil > 86_400) { + return ""; + } + + const unit = naturalUnit(secsUntil); + const amount = Math.round(unitAmount(unit, secsUntil)); + const unitStr = unitName(unit); + const nextLearnDue = i18n.tr(i18n.TR.SCHEDULING_NEXT_LEARN_DUE, { + amount, + unit: unitStr, + }); + const remaining = i18n.tr(i18n.TR.SCHEDULING_LEARN_REMAINING, { + remaining: info.learnRemaining, + }); + return `${nextLearnDue} ${remaining}`; +} diff --git a/ts/src/scss/core.scss b/ts/src/scss/core.scss index 01a3917ce..e0a2a8264 100644 --- a/ts/src/scss/core.scss +++ b/ts/src/scss/core.scss @@ -15,4 +15,5 @@ body { a { color: var(--link); + text-decoration: none; } diff --git a/ts/src/stats/graphs-bootstrap.ts b/ts/src/stats/graphs-bootstrap.ts index 40b21a146..0056d536a 100644 --- a/ts/src/stats/graphs-bootstrap.ts +++ b/ts/src/stats/graphs-bootstrap.ts @@ -3,12 +3,13 @@ import { setupI18n } from "../i18n"; import GraphsPage from "./GraphsPage.svelte"; +import { checkNightMode } from "../nightmode"; -export function graphs(target: HTMLDivElement, nightMode: boolean): void { +export function graphs(target: HTMLDivElement): void { setupI18n().then((i18n) => { new GraphsPage({ target, - props: { i18n, nightMode }, + props: { i18n, nightMode: checkNightMode() }, }); }); } diff --git a/ts/src/stats/graphs.ts b/ts/src/stats/graphs.ts index cbb77d895..e4098d606 100644 --- a/ts/src/stats/graphs.ts +++ b/ts/src/stats/graphs.ts @@ -8,31 +8,15 @@ import pb from "../backend/proto"; import { Selection } from "d3-selection"; - -async function fetchData(search: string, days: number): Promise { - const resp = await fetch("/_anki/graphData", { - method: "POST", - body: JSON.stringify({ - search, - days, - }), - }); - if (!resp.ok) { - throw Error(`unexpected reply: ${resp.statusText}`); - } - // get returned bytes - const respBlob = await resp.blob(); - const respBuf = await new Response(respBlob).arrayBuffer(); - const bytes = new Uint8Array(respBuf); - return bytes; -} +import { postRequest } from "../postrequest"; export async function getGraphData( search: string, days: number ): Promise { - const bytes = await fetchData(search, days); - return pb.BackendProto.GraphsOut.decode(bytes); + return pb.BackendProto.GraphsOut.decode( + await postRequest("/_anki/graphData", JSON.stringify({ search, days })) + ); } // amount of data to fetch from backend diff --git a/ts/src/time.ts b/ts/src/time.ts index cb627c583..3635398a6 100644 --- a/ts/src/time.ts +++ b/ts/src/time.ts @@ -19,7 +19,7 @@ enum TimespanUnit { Years, } -function unitName(unit: TimespanUnit): string { +export function unitName(unit: TimespanUnit): string { switch (unit) { case TimespanUnit.Seconds: return "seconds"; @@ -36,7 +36,7 @@ function unitName(unit: TimespanUnit): string { } } -function naturalUnit(secs: number): TimespanUnit { +export function naturalUnit(secs: number): TimespanUnit { secs = Math.abs(secs); if (secs < MINUTE) { return TimespanUnit.Seconds; @@ -53,7 +53,7 @@ function naturalUnit(secs: number): TimespanUnit { } } -function unitAmount(unit: TimespanUnit, secs: number): number { +export function unitAmount(unit: TimespanUnit, secs: number): number { switch (unit) { case TimespanUnit.Seconds: return secs; diff --git a/ts/webpack.config.js b/ts/webpack.config.js index 567b5348c..da3333590 100644 --- a/ts/webpack.config.js +++ b/ts/webpack.config.js @@ -6,6 +6,7 @@ var path = require("path"); module.exports = { entry: { graphs: ["./src/stats/graphs-bootstrap.ts"], + congrats: ["./src/sched/congrats-bootstrap.ts"], }, output: { library: "anki", @@ -16,6 +17,11 @@ module.exports = { chunks: ["graphs"], template: "src/html/graphs.html", }), + new HtmlWebpackPlugin({ + filename: "congrats.html", + chunks: ["congrats"], + template: "src/html/congrats.html", + }), ], externals: { moment: "moment",