From 41d77b02553c58f4e81a98142a175e6858df9b58 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sat, 27 Jun 2020 19:24:49 +1000 Subject: [PATCH] get i18n working in typescript --- proto/backend.proto | 1 + qt/aqt/mediasrv.py | 2 + rslib/src/backend/mod.rs | 6 + rslib/src/i18n/mod.rs | 187 +++++++++++++++++-------------- rspy/src/lib.rs | 1 + ts/package.json | 1 + ts/src/html/graphs.html | 4 +- ts/src/i18n.ts | 57 ++++++++++ ts/src/stats/GraphsPage.svelte | 7 +- ts/src/stats/TodayStats.svelte | 17 ++- ts/src/stats/graphs-bootstrap.ts | 14 +++ ts/src/stats/today.ts | 13 ++- ts/src/time.ts | 89 +++++++++++++++ ts/webpack.config.js | 2 +- 14 files changed, 300 insertions(+), 101 deletions(-) create mode 100644 ts/src/i18n.ts create mode 100644 ts/src/stats/graphs-bootstrap.ts create mode 100644 ts/src/time.ts diff --git a/proto/backend.proto b/proto/backend.proto index b806ceb41..84ab38fc3 100644 --- a/proto/backend.proto +++ b/proto/backend.proto @@ -183,6 +183,7 @@ service BackendService { rpc TranslateString (TranslateStringIn) returns (String); rpc FormatTimespan (FormatTimespanIn) returns (String); + rpc I18nResources (Empty) returns (Json); // tags diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index d0b562a5a..32d5fdf0d 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -185,6 +185,8 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler): content_length = int(self.headers["Content-Length"]) body = self.rfile.read(content_length) data = graph_data(self.mw.col, **from_json_bytes(body)) + elif cmd == "i18nResources": + data = self.mw.col.backend.i18n_resources() else: self.send_error(HTTPStatus.NOT_FOUND, "Method not found") return diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 850e32648..2a8679217 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -1167,6 +1167,12 @@ impl BackendService for Backend { .into()) } + fn i18n_resources(&mut self, _input: Empty) -> BackendResult { + serde_json::to_vec(&self.i18n.resources_for_js()) + .map(Into::into) + .map_err(Into::into) + } + // tags //------------------------------------------------------------------- diff --git a/rslib/src/i18n/mod.rs b/rslib/src/i18n/mod.rs index d7e5980f6..97749d905 100644 --- a/rslib/src/i18n/mod.rs +++ b/rslib/src/i18n/mod.rs @@ -6,6 +6,7 @@ use crate::log::{error, Logger}; use fluent::{FluentArgs, FluentBundle, FluentResource, FluentValue}; use intl_memoizer::IntlLangMemoizer; use num_format::Locale; +use serde::Serialize; use std::borrow::Cow; use std::fs; use std::path::{Path, PathBuf}; @@ -64,79 +65,76 @@ fn lang_folder(lang: Option<&LanguageIdentifier>, ftl_folder: &Path) -> Option

String { - include_str!("ftl/template.ftl").to_string() +fn ftl_template_text() -> &'static str { + include_str!("ftl/template.ftl") } -fn ftl_localized_text(lang: &LanguageIdentifier) -> Option { - Some( - match lang.language() { - "en" => { - match lang.region() { - Some("GB") | Some("AU") => include_str!("ftl/en-GB.ftl"), - // use fallback language instead - _ => return None, - } +fn ftl_localized_text(lang: &LanguageIdentifier) -> Option<&'static str> { + Some(match lang.language() { + "en" => { + match lang.region() { + Some("GB") | Some("AU") => include_str!("ftl/en-GB.ftl"), + // use fallback language instead + _ => return None, } - "zh" => match lang.region() { - Some("TW") | Some("HK") => include_str!("ftl/zh-TW.ftl"), - _ => include_str!("ftl/zh-CN.ftl"), - }, - "pt" => { - if let Some("PT") = lang.region() { - include_str!("ftl/pt-PT.ftl") - } else { - include_str!("ftl/pt-BR.ftl") - } - } - "ga" => include_str!("ftl/ga-IE.ftl"), - "hy" => include_str!("ftl/hy-AM.ftl"), - "nb" => include_str!("ftl/nb-NO.ftl"), - "sv" => include_str!("ftl/sv-SE.ftl"), - "jbo" => include_str!("ftl/jbo.ftl"), - "kab" => include_str!("ftl/kab.ftl"), - "af" => include_str!("ftl/af.ftl"), - "ar" => include_str!("ftl/ar.ftl"), - "bg" => include_str!("ftl/bg.ftl"), - "ca" => include_str!("ftl/ca.ftl"), - "cs" => include_str!("ftl/cs.ftl"), - "da" => include_str!("ftl/da.ftl"), - "de" => include_str!("ftl/de.ftl"), - "el" => include_str!("ftl/el.ftl"), - "eo" => include_str!("ftl/eo.ftl"), - "es" => include_str!("ftl/es.ftl"), - "et" => include_str!("ftl/et.ftl"), - "eu" => include_str!("ftl/eu.ftl"), - "fa" => include_str!("ftl/fa.ftl"), - "fi" => include_str!("ftl/fi.ftl"), - "fr" => include_str!("ftl/fr.ftl"), - "gl" => include_str!("ftl/gl.ftl"), - "he" => include_str!("ftl/he.ftl"), - "hr" => include_str!("ftl/hr.ftl"), - "hu" => include_str!("ftl/hu.ftl"), - "it" => include_str!("ftl/it.ftl"), - "ja" => include_str!("ftl/ja.ftl"), - "ko" => include_str!("ftl/ko.ftl"), - "la" => include_str!("ftl/la.ftl"), - "mn" => include_str!("ftl/mn.ftl"), - "mr" => include_str!("ftl/mr.ftl"), - "ms" => include_str!("ftl/ms.ftl"), - "nl" => include_str!("ftl/nl.ftl"), - "oc" => include_str!("ftl/oc.ftl"), - "pl" => include_str!("ftl/pl.ftl"), - "ro" => include_str!("ftl/ro.ftl"), - "ru" => include_str!("ftl/ru.ftl"), - "sk" => include_str!("ftl/sk.ftl"), - "sl" => include_str!("ftl/sl.ftl"), - "sr" => include_str!("ftl/sr.ftl"), - "th" => include_str!("ftl/th.ftl"), - "tr" => include_str!("ftl/tr.ftl"), - "uk" => include_str!("ftl/uk.ftl"), - "vi" => include_str!("ftl/vi.ftl"), - _ => return None, } - .to_string(), - ) + "zh" => match lang.region() { + Some("TW") | Some("HK") => include_str!("ftl/zh-TW.ftl"), + _ => include_str!("ftl/zh-CN.ftl"), + }, + "pt" => { + if let Some("PT") = lang.region() { + include_str!("ftl/pt-PT.ftl") + } else { + include_str!("ftl/pt-BR.ftl") + } + } + "ga" => include_str!("ftl/ga-IE.ftl"), + "hy" => include_str!("ftl/hy-AM.ftl"), + "nb" => include_str!("ftl/nb-NO.ftl"), + "sv" => include_str!("ftl/sv-SE.ftl"), + "jbo" => include_str!("ftl/jbo.ftl"), + "kab" => include_str!("ftl/kab.ftl"), + "af" => include_str!("ftl/af.ftl"), + "ar" => include_str!("ftl/ar.ftl"), + "bg" => include_str!("ftl/bg.ftl"), + "ca" => include_str!("ftl/ca.ftl"), + "cs" => include_str!("ftl/cs.ftl"), + "da" => include_str!("ftl/da.ftl"), + "de" => include_str!("ftl/de.ftl"), + "el" => include_str!("ftl/el.ftl"), + "eo" => include_str!("ftl/eo.ftl"), + "es" => include_str!("ftl/es.ftl"), + "et" => include_str!("ftl/et.ftl"), + "eu" => include_str!("ftl/eu.ftl"), + "fa" => include_str!("ftl/fa.ftl"), + "fi" => include_str!("ftl/fi.ftl"), + "fr" => include_str!("ftl/fr.ftl"), + "gl" => include_str!("ftl/gl.ftl"), + "he" => include_str!("ftl/he.ftl"), + "hr" => include_str!("ftl/hr.ftl"), + "hu" => include_str!("ftl/hu.ftl"), + "it" => include_str!("ftl/it.ftl"), + "ja" => include_str!("ftl/ja.ftl"), + "ko" => include_str!("ftl/ko.ftl"), + "la" => include_str!("ftl/la.ftl"), + "mn" => include_str!("ftl/mn.ftl"), + "mr" => include_str!("ftl/mr.ftl"), + "ms" => include_str!("ftl/ms.ftl"), + "nl" => include_str!("ftl/nl.ftl"), + "oc" => include_str!("ftl/oc.ftl"), + "pl" => include_str!("ftl/pl.ftl"), + "ro" => include_str!("ftl/ro.ftl"), + "ru" => include_str!("ftl/ru.ftl"), + "sk" => include_str!("ftl/sk.ftl"), + "sl" => include_str!("ftl/sl.ftl"), + "sr" => include_str!("ftl/sr.ftl"), + "th" => include_str!("ftl/th.ftl"), + "tr" => include_str!("ftl/tr.ftl"), + "uk" => include_str!("ftl/uk.ftl"), + "vi" => include_str!("ftl/vi.ftl"), + _ => return None, + }) } /// Return the text from any .ftl files in the given folder. @@ -163,12 +161,12 @@ fn ftl_external_text(folder: &Path) -> Result { /// at runtime. If it contains errors, they will not prevent a /// bundle from being returned. fn get_bundle( - text: String, + text: &str, extra_text: String, locales: &[LanguageIdentifier], log: &Logger, ) -> Option> { - let res = FluentResource::try_new(text) + let res = FluentResource::try_new(text.into()) .map_err(|e| { error!(log, "Unable to parse translations file: {:?}", e); }) @@ -202,7 +200,7 @@ fn get_bundle( /// Get a bundle that includes any filesystem overrides. fn get_bundle_with_extra( - text: String, + text: &str, lang: Option<&LanguageIdentifier>, ftl_folder: &Path, locales: &[LanguageIdentifier], @@ -236,14 +234,20 @@ impl I18n { log: Logger, ) -> Self { let ftl_folder = ftl_folder.into(); - let mut langs = vec![]; let mut bundles = Vec::with_capacity(locale_codes.len() + 1); + let mut resource_text = vec![]; for code in locale_codes { let code = code.as_ref(); if let Ok(lang) = code.parse::() { langs.push(lang.clone()); + if lang.language() == "en" { + // if English was listed, any further preferences are skipped, + // as the template has 100% coverage, and we need to ensure + // it is tried prior to any other langs. + break; + } } } // add fallback date/time @@ -255,29 +259,27 @@ impl I18n { if let Some(bundle) = get_bundle_with_extra(text, Some(lang), &ftl_folder, &langs, &log) { + resource_text.push(text); bundles.push(bundle); } else { error!(log, "Failed to create bundle for {:?}", lang.language()) } } - - // if English was listed, any further preferences are skipped, - // as the template has 100% coverage, and we need to ensure - // it is tried prior to any other langs. But we do keep a file - // if one was returned, to allow locale English variants to take - // priority over the template. - if lang.language() == "en" { - break; - } } // add English templates + let template_text = ftl_template_text(); let template_bundle = - get_bundle_with_extra(ftl_template_text(), None, &ftl_folder, &langs, &log).unwrap(); + get_bundle_with_extra(template_text, None, &ftl_folder, &langs, &log).unwrap(); + resource_text.push(template_text); bundles.push(template_bundle); Self { - inner: Arc::new(Mutex::new(I18nInner { bundles })), + inner: Arc::new(Mutex::new(I18nInner { + bundles, + langs, + resource_text, + })), log, } } @@ -320,12 +322,23 @@ impl I18n { // return the key name if it was missing key.to_string().into() } + + /// Return text from configured locales for use with the JS Fluent implementation. + pub fn resources_for_js(&self) -> ResourcesForJavascript { + let inner = self.inner.lock().unwrap(); + ResourcesForJavascript { + langs: inner.langs.iter().map(ToString::to_string).collect(), + resources: inner.resource_text.clone(), + } + } } struct I18nInner { // bundles in preferred language order, with template English as the // last element bundles: Vec>, + langs: Vec, + resource_text: Vec<&'static str>, } fn set_bundle_formatter_for_langs(bundle: &mut FluentBundle, langs: &[LanguageIdentifier]) { @@ -394,6 +407,12 @@ impl NumberFormatter { } } +#[derive(Serialize)] +pub struct ResourcesForJavascript { + langs: Vec, + resources: Vec<&'static str>, +} + #[cfg(test)] mod test { use crate::i18n::NumberFormatter; diff --git a/rspy/src/lib.rs b/rspy/src/lib.rs index d2223541a..50261cbd7 100644 --- a/rspy/src/lib.rs +++ b/rspy/src/lib.rs @@ -119,6 +119,7 @@ fn want_release_gil(method: u32) -> bool { BackendMethod::CountsForDeckToday => true, BackendMethod::CardStats => true, BackendMethod::Graphs => true, + BackendMethod::I18nResources => false, } } else { false diff --git a/ts/package.json b/ts/package.json index 543952568..0dfd618e5 100644 --- a/ts/package.json +++ b/ts/package.json @@ -49,6 +49,7 @@ "dev": "webpack-dev-server" }, "dependencies": { + "@fluent/bundle": "^0.15.1", "d3-array": "^2.4.0", "d3-axis": "^1.0.12", "d3-scale": "^3.2.1", diff --git a/ts/src/html/graphs.html b/ts/src/html/graphs.html index 3d17e5f65..9ed95ba15 100644 --- a/ts/src/html/graphs.html +++ b/ts/src/html/graphs.html @@ -8,8 +8,6 @@

diff --git a/ts/src/i18n.ts b/ts/src/i18n.ts new file mode 100644 index 000000000..865bbcbfe --- /dev/null +++ b/ts/src/i18n.ts @@ -0,0 +1,57 @@ +// 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 { FluentBundle, FluentResource, FluentNumber } from "@fluent/bundle"; + +function formatNumbers(args?: Record): void { + if (!args) { + return; + } + + for (const key of Object.keys(args)) { + if (typeof args[key] === "number") { + args[key] = new FluentNumber(args[key], { maximumSignificantDigits: 2 }); + } + } +} + +export class I18n { + bundles: FluentBundle[] = []; + TR = pb.BackendProto.FluentString; + + tr(id: pb.BackendProto.FluentString, args?: Record): string { + formatNumbers(args); + const key = this.keyName(id); + for (const bundle of this.bundles) { + const msg = bundle.getMessage(key); + if (msg && msg.value) { + return bundle.formatPattern(msg.value, args); + } + } + return `missing key: ${key}`; + } + + private keyName(msg: pb.BackendProto.FluentString): string { + return this.TR[msg].toLowerCase().replace(/_/g, "-"); + } +} + +export async function setupI18n(): Promise { + const i18n = new I18n(); + + const resp = await fetch("/_anki/i18nResources", { method: "POST" }); + if (!resp.ok) { + throw Error(`unexpected reply: ${resp.statusText}`); + } + const json = await resp.json(); + + for (const resourceText of json.resources) { + const bundle = new FluentBundle(json.langs); + const resource = new FluentResource(resourceText); + bundle.addResource(resource); + i18n.bundles.push(bundle); + } + + return i18n; +} diff --git a/ts/src/stats/GraphsPage.svelte b/ts/src/stats/GraphsPage.svelte index 22649d14f..75de63eb9 100644 --- a/ts/src/stats/GraphsPage.svelte +++ b/ts/src/stats/GraphsPage.svelte @@ -1,11 +1,10 @@ -
-

Today

- {JSON.stringify(todayData)} -
+{#if todayData} +
+

Today

+ +
{todayData.studiedToday}
+ {JSON.stringify(todayData)} +
+{/if} diff --git a/ts/src/stats/graphs-bootstrap.ts b/ts/src/stats/graphs-bootstrap.ts new file mode 100644 index 000000000..a06537597 --- /dev/null +++ b/ts/src/stats/graphs-bootstrap.ts @@ -0,0 +1,14 @@ +// 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 GraphsPage from "./GraphsPage.svelte"; + +export function graphs(target: HTMLDivElement) { + setupI18n().then((i18n) => { + new GraphsPage({ + target, + props: { i18n }, + }); + }); +} diff --git a/ts/src/stats/today.ts b/ts/src/stats/today.ts index 6fce03c1f..e5a7c6215 100644 --- a/ts/src/stats/today.ts +++ b/ts/src/stats/today.ts @@ -2,10 +2,10 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import pb from "../backend/proto"; +import { studiedToday } from "../time"; +import { I18n } from "../i18n"; export interface TodayData { - answerCount: number; - answerMillis: number; correctCount: number; matureCorrect: number; matureCount: number; @@ -13,11 +13,13 @@ export interface TodayData { reviewCount: number; relearnCount: number; earlyReviewCount: number; + + studiedToday: string; } const ReviewKind = pb.BackendProto.RevlogEntry.ReviewKind; -export function gatherData(data: pb.BackendProto.GraphsOut): TodayData { +export function gatherData(data: pb.BackendProto.GraphsOut, i18n: I18n): TodayData { let answerCount = 0; let answerMillis = 0; let correctCount = 0; @@ -69,9 +71,10 @@ export function gatherData(data: pb.BackendProto.GraphsOut): TodayData { } } + const studiedTodayText = studiedToday(i18n, answerCount, answerMillis / 1000); + return { - answerCount, - answerMillis, + studiedToday: studiedTodayText, correctCount, matureCorrect, matureCount, diff --git a/ts/src/time.ts b/ts/src/time.ts new file mode 100644 index 000000000..d28a10b0e --- /dev/null +++ b/ts/src/time.ts @@ -0,0 +1,89 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import { I18n } from "./i18n"; +import { FluentNumber } from "@fluent/bundle"; + +const SECOND = 1.0; +const MINUTE = 60.0 * SECOND; +const HOUR = 60.0 * MINUTE; +const DAY = 24.0 * HOUR; +const MONTH = 30.0 * DAY; +const YEAR = 12.0 * MONTH; + +enum TimespanUnit { + Seconds, + Minutes, + Hours, + Days, + Months, + Years, +} + +function unitName(unit: TimespanUnit): string { + switch (unit) { + case TimespanUnit.Seconds: + return "seconds"; + case TimespanUnit.Minutes: + return "minutes"; + case TimespanUnit.Hours: + return "hours"; + case TimespanUnit.Days: + return "days"; + case TimespanUnit.Months: + return "months"; + case TimespanUnit.Years: + return "years"; + } +} + +function naturalUnit(secs: number): TimespanUnit { + secs = Math.abs(secs); + if (secs < MINUTE) { + return TimespanUnit.Seconds; + } else if (secs < HOUR) { + return TimespanUnit.Minutes; + } else if (secs < DAY) { + return TimespanUnit.Hours; + } else if (secs < MONTH) { + return TimespanUnit.Days; + } else if (secs < YEAR) { + return TimespanUnit.Months; + } else { + return TimespanUnit.Years; + } +} + +function unitAmount(unit: TimespanUnit, secs: number): number { + switch (unit) { + case TimespanUnit.Seconds: + return secs; + case TimespanUnit.Minutes: + return secs / MINUTE; + case TimespanUnit.Hours: + return secs / HOUR; + case TimespanUnit.Days: + return secs / DAY; + case TimespanUnit.Months: + return secs / MONTH; + case TimespanUnit.Years: + return secs / YEAR; + } +} + +export function studiedToday(i18n: I18n, cards: number, secs: number): string { + const unit = naturalUnit(secs); + const amount = unitAmount(unit, secs); + const name = unitName(unit); + + let secsPer = 0; + if (cards > 0) { + secsPer = secs / cards; + } + return i18n.tr(i18n.TR.STATISTICS_STUDIED_TODAY, { + cards, + amount, + unit: name, + "secs-per-card": secsPer, + }); +} diff --git a/ts/webpack.config.js b/ts/webpack.config.js index b48784b6b..313ccdd08 100644 --- a/ts/webpack.config.js +++ b/ts/webpack.config.js @@ -5,7 +5,7 @@ var path = require("path"); module.exports = { entry: { - graphs: ["./src/stats/GraphsPage.svelte"], + graphs: ["./src/stats/graphs-bootstrap.ts"], }, output: { library: "anki",