reimplement congrats screen in Rust+Typescript

This commit is contained in:
Damien Elmes 2020-08-27 21:46:34 +10:00
parent 5520163bf7
commit e5685254c6
33 changed files with 412 additions and 245 deletions

View file

@ -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;
}

View file

@ -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

View file

@ -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 (
"<b>"
+ _("Congratulations! You have finished this deck for now.")
+ "</b><br><br>"
+ 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 "<p>".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
##########################################################################

View file

@ -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()

View file

@ -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()

1
qt/.gitignore vendored
View file

@ -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

View file

@ -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,)

View file

@ -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 = '<a class=smallLink href="review">Reviews and Updates</a>'
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 '<div class="descfont descmid description %s">%s</div>' % (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 '<div style="white-space: pre-wrap;">%s</div>' % (
self.mw.col.sched.finishedMsg()
)
return None
else:
return """
<table width=400 cellpadding=5>

View file

@ -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."""

View file

@ -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(),
)

View file

@ -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

View file

@ -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<pb::String> {
Ok(learning_congrats(input.remaining as usize, input.next_due, &self.i18n).into())
}
fn update_stats(&mut self, input: pb::UpdateStatsIn) -> BackendResult<Empty> {
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<pb::CongratsInfoOut> {
self.with_col(|col| col.congrats_info())
}
// statistics
//-----------------------------------------------

View file

@ -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<pb::CongratsInfoOut> {
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,
})
}
}

View file

@ -5,6 +5,7 @@ use crate::{
collection::Collection, config::SchedulerVersion, err::Result, timestamp::TimestampSecs,
};
pub(crate) mod congrats;
pub mod cutoff;
pub mod timespan;

View file

@ -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."
);
}
}

View file

@ -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
)

View file

@ -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<CongratsInfo> {
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)]

View file

@ -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 = &current.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<()> {

View file

@ -0,0 +1,8 @@
insert into active_decks
select id
from decks
where name = ?
or (
name >= ?
and name < ?
)

View file

@ -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

7
ts/src/bridgecommand.ts Normal file
View file

@ -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 <a> tag pointing to a bridge command.
export function bridgeLink(command: string, label: string): string {
return `<a href="javascript:bridgeCommand('${command}')">${label}</a>`;
}

13
ts/src/html/congrats.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" id="viewport" content="width=device-width" />
</head>
<body>
<div id="main"></div>
</body>
<script>
anki.congrats(document.getElementById("main"));
</script>
</html>

View file

@ -8,10 +8,6 @@
<div id="main"></div>
</body>
<script>
const nightMode = window.location.hash == "#night";
if (nightMode) {
document.documentElement.className = "night-mode";
}
anki.graphs(document.getElementById("main"), nightMode);
anki.graphs(document.getElementById("main"));
</script>
</html>

12
ts/src/nightmode.ts Normal file
View file

@ -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;
}

17
ts/src/postrequest.ts Normal file
View file

@ -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<Uint8Array> {
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;
}

View file

@ -0,0 +1,67 @@
<script context="module">
import _ from "../scss/core.scss";
</script>
<script lang="ts">
import { I18n } from "../i18n";
import pb from "../backend/proto";
import { buildNextLearnMsg } from "./congrats";
import { bridgeLink } from "../bridgecommand";
export let info: pb.BackendProto.CongratsInfoOut;
export let i18n: I18n;
const congrats = i18n.tr(i18n.TR.SCHEDULING_CONGRATULATIONS_FINISHED);
const nextLearnMsg = buildNextLearnMsg(info, i18n);
const today_reviews = i18n.tr(i18n.TR.SCHEDULING_TODAY_REVIEW_LIMIT_REACHED);
const today_new = i18n.tr(i18n.TR.SCHEDULING_TODAY_NEW_LIMIT_REACHED);
const unburyThem = bridgeLink("unbury", i18n.tr(i18n.TR.SCHEDULING_UNBURY_THEM));
const buriedMsg = i18n.tr(i18n.TR.SCHEDULING_BURIED_CARDS_FOUND, { unburyThem });
const customStudy = bridgeLink(
"customStudy",
i18n.tr(i18n.TR.SCHEDULING_CUSTOM_STUDY)
);
const customStudyMsg = i18n.tr(i18n.TR.SCHEDULING_HOW_TO_CUSTOM_STUDY, {
customStudy,
});
</script>
<style>
.congrats-outer {
display: flex;
justify-content: center;
}
.congrats-inner {
max-width: 30em;
}
</style>
<div class="congrats-outer">
<div class="congrats-inner">
<h3>{congrats}</h3>
<p>{nextLearnMsg}</p>
{#if info.reviewRemaining}
<p>{today_reviews}</p>
{/if}
{#if info.newRemaining}
<p>{today_new}</p>
{/if}
{#if info.bridgeCommandsSupported}
{#if info.haveSchedBuried || info.haveUserBuried}
<p>
{@html buriedMsg}
</p>
{/if}
<p>
{@html customStudyMsg}
</p>
{/if}
</div>
</div>

View file

@ -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<void> {
checkNightMode();
const i18n = await setupI18n();
const info = await getCongratsInfo();
new CongratsPage({
target,
props: { info, i18n },
});
}

36
ts/src/sched/congrats.ts Normal file
View file

@ -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<pb.BackendProto.CongratsInfoOut> {
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}`;
}

View file

@ -15,4 +15,5 @@ body {
a {
color: var(--link);
text-decoration: none;
}

View file

@ -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() },
});
});
}

View file

@ -8,31 +8,15 @@
import pb from "../backend/proto";
import { Selection } from "d3-selection";
async function fetchData(search: string, days: number): Promise<Uint8Array> {
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<pb.BackendProto.GraphsOut> {
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

View file

@ -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;

View file

@ -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",