get i18n working in typescript

This commit is contained in:
Damien Elmes 2020-06-27 19:24:49 +10:00
parent 0f1f80aebc
commit 41d77b0255
14 changed files with 300 additions and 101 deletions

View file

@ -183,6 +183,7 @@ service BackendService {
rpc TranslateString (TranslateStringIn) returns (String);
rpc FormatTimespan (FormatTimespanIn) returns (String);
rpc I18nResources (Empty) returns (Json);
// tags

View file

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

View file

@ -1167,6 +1167,12 @@ impl BackendService for Backend {
.into())
}
fn i18n_resources(&mut self, _input: Empty) -> BackendResult<pb::Json> {
serde_json::to_vec(&self.i18n.resources_for_js())
.map(Into::into)
.map_err(Into::into)
}
// tags
//-------------------------------------------------------------------

View file

@ -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<P
/// Get the template/English resource text for the given group.
/// These are embedded in the binary.
fn ftl_template_text() -> 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<String> {
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<String> {
/// 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<FluentBundle<FluentResource>> {
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::<LanguageIdentifier>() {
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<FluentBundle<FluentResource>>,
langs: Vec<LanguageIdentifier>,
resource_text: Vec<&'static str>,
}
fn set_bundle_formatter_for_langs<T>(bundle: &mut FluentBundle<T>, langs: &[LanguageIdentifier]) {
@ -394,6 +407,12 @@ impl NumberFormatter {
}
}
#[derive(Serialize)]
pub struct ResourcesForJavascript {
langs: Vec<String>,
resources: Vec<&'static str>,
}
#[cfg(test)]
mod test {
use crate::i18n::NumberFormatter;

View file

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

View file

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

View file

@ -8,8 +8,6 @@
<div id="main"></div>
</body>
<script>
new anki.default({
target: document.getElementById("main"),
});
anki.graphs(document.getElementById("main"));
</script>
</html>

57
ts/src/i18n.ts Normal file
View file

@ -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<string, any>): 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, any>): 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<I18n> {
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;
}

View file

@ -1,11 +1,10 @@
<script context="module">
import style from "./graphs.css";
document.head.append(style);
</script>
<script lang="typescript">
import { assertUnreachable } from "../typing";
import { I18n } from "../i18n";
import pb from "../backend/proto";
import { getGraphData, RevlogRange } from "./graphs";
import IntervalsGraph from "./IntervalsGraph.svelte";
@ -18,6 +17,8 @@
import FutureDue from "./FutureDue.svelte";
import ReviewsGraph from "./ReviewsGraph.svelte";
export let i18n: I18n;
let sourceData: pb.BackendProto.GraphsOut | null = null;
enum SearchRange {
@ -123,7 +124,7 @@
</div>
<div class="range-box-pad" />
<TodayStats {sourceData} />
<TodayStats {sourceData} {i18n} />
<CardCounts {sourceData} />
<FutureDue {sourceData} />
<ReviewsGraph {sourceData} {revlogRange} />

View file

@ -1,17 +1,24 @@
<script lang="typescript">
import { gatherData, TodayData } from "./today";
import { studiedToday } from "../time";
import pb from "../backend/proto";
import { I18n } from "../i18n";
export let sourceData: pb.BackendProto.GraphsOut | null = null;
export let i18n: I18n;
let todayData: TodayData | null = null;
$: if (sourceData) {
console.log("gathering data");
todayData = gatherData(sourceData);
todayData = gatherData(sourceData, i18n);
}
</script>
<div class="graph">
<h1>Today</h1>
{JSON.stringify(todayData)}
</div>
{#if todayData}
<div class="graph">
<h1>Today</h1>
<div>{todayData.studiedToday}</div>
{JSON.stringify(todayData)}
</div>
{/if}

View file

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

View file

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

89
ts/src/time.ts Normal file
View file

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

View file

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