mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
reimplement congrats screen in Rust+Typescript
This commit is contained in:
parent
5520163bf7
commit
e5685254c6
33 changed files with 412 additions and 245 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
##########################################################################
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
1
qt/.gitignore
vendored
|
@ -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
|
||||
|
|
|
@ -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,)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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."""
|
||||
|
||||
|
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
//-----------------------------------------------
|
||||
|
||||
|
|
38
rslib/src/sched/congrats.rs
Normal file
38
rslib/src/sched/congrats.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ use crate::{
|
|||
collection::Collection, config::SchedulerVersion, err::Result, timestamp::TimestampSecs,
|
||||
};
|
||||
|
||||
pub(crate) mod congrats;
|
||||
pub mod cutoff;
|
||||
pub mod timespan;
|
||||
|
||||
|
|
|
@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
22
rslib/src/storage/card/congrats.sql
Normal file
22
rslib/src/storage/card/congrats.sql
Normal 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
|
||||
)
|
|
@ -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)]
|
||||
|
|
|
@ -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<()> {
|
||||
|
|
8
rslib/src/storage/deck/update_active.sql
Normal file
8
rslib/src/storage/deck/update_active.sql
Normal file
|
@ -0,0 +1,8 @@
|
|||
insert into active_decks
|
||||
select id
|
||||
from decks
|
||||
where name = ?
|
||||
or (
|
||||
name >= ?
|
||||
and name < ?
|
||||
)
|
|
@ -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
7
ts/src/bridgecommand.ts
Normal 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
13
ts/src/html/congrats.html
Normal 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>
|
|
@ -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
12
ts/src/nightmode.ts
Normal 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
17
ts/src/postrequest.ts
Normal 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;
|
||||
}
|
67
ts/src/sched/CongratsPage.svelte
Normal file
67
ts/src/sched/CongratsPage.svelte
Normal 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>
|
17
ts/src/sched/congrats-bootstrap.ts
Normal file
17
ts/src/sched/congrats-bootstrap.ts
Normal 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
36
ts/src/sched/congrats.ts
Normal 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}`;
|
||||
}
|
|
@ -15,4 +15,5 @@ body {
|
|||
|
||||
a {
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
|
|
@ -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() },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue