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 TranslateString (TranslateStringIn) returns (String);
rpc FormatTimespan (FormatTimespanIn) returns (String); rpc FormatTimespan (FormatTimespanIn) returns (String);
rpc I18nResources (Empty) returns (Json);
// tags // tags

View file

@ -185,6 +185,8 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler):
content_length = int(self.headers["Content-Length"]) content_length = int(self.headers["Content-Length"])
body = self.rfile.read(content_length) body = self.rfile.read(content_length)
data = graph_data(self.mw.col, **from_json_bytes(body)) data = graph_data(self.mw.col, **from_json_bytes(body))
elif cmd == "i18nResources":
data = self.mw.col.backend.i18n_resources()
else: else:
self.send_error(HTTPStatus.NOT_FOUND, "Method not found") self.send_error(HTTPStatus.NOT_FOUND, "Method not found")
return return

View file

@ -1167,6 +1167,12 @@ impl BackendService for Backend {
.into()) .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 // tags
//------------------------------------------------------------------- //-------------------------------------------------------------------

View file

@ -6,6 +6,7 @@ use crate::log::{error, Logger};
use fluent::{FluentArgs, FluentBundle, FluentResource, FluentValue}; use fluent::{FluentArgs, FluentBundle, FluentResource, FluentValue};
use intl_memoizer::IntlLangMemoizer; use intl_memoizer::IntlLangMemoizer;
use num_format::Locale; use num_format::Locale;
use serde::Serialize;
use std::borrow::Cow; use std::borrow::Cow;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -64,13 +65,12 @@ fn lang_folder(lang: Option<&LanguageIdentifier>, ftl_folder: &Path) -> Option<P
/// Get the template/English resource text for the given group. /// Get the template/English resource text for the given group.
/// These are embedded in the binary. /// These are embedded in the binary.
fn ftl_template_text() -> String { fn ftl_template_text() -> &'static str {
include_str!("ftl/template.ftl").to_string() include_str!("ftl/template.ftl")
} }
fn ftl_localized_text(lang: &LanguageIdentifier) -> Option<String> { fn ftl_localized_text(lang: &LanguageIdentifier) -> Option<&'static str> {
Some( Some(match lang.language() {
match lang.language() {
"en" => { "en" => {
match lang.region() { match lang.region() {
Some("GB") | Some("AU") => include_str!("ftl/en-GB.ftl"), Some("GB") | Some("AU") => include_str!("ftl/en-GB.ftl"),
@ -134,9 +134,7 @@ fn ftl_localized_text(lang: &LanguageIdentifier) -> Option<String> {
"uk" => include_str!("ftl/uk.ftl"), "uk" => include_str!("ftl/uk.ftl"),
"vi" => include_str!("ftl/vi.ftl"), "vi" => include_str!("ftl/vi.ftl"),
_ => return None, _ => return None,
} })
.to_string(),
)
} }
/// Return the text from any .ftl files in the given folder. /// 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 /// at runtime. If it contains errors, they will not prevent a
/// bundle from being returned. /// bundle from being returned.
fn get_bundle( fn get_bundle(
text: String, text: &str,
extra_text: String, extra_text: String,
locales: &[LanguageIdentifier], locales: &[LanguageIdentifier],
log: &Logger, log: &Logger,
) -> Option<FluentBundle<FluentResource>> { ) -> Option<FluentBundle<FluentResource>> {
let res = FluentResource::try_new(text) let res = FluentResource::try_new(text.into())
.map_err(|e| { .map_err(|e| {
error!(log, "Unable to parse translations file: {:?}", e); error!(log, "Unable to parse translations file: {:?}", e);
}) })
@ -202,7 +200,7 @@ fn get_bundle(
/// Get a bundle that includes any filesystem overrides. /// Get a bundle that includes any filesystem overrides.
fn get_bundle_with_extra( fn get_bundle_with_extra(
text: String, text: &str,
lang: Option<&LanguageIdentifier>, lang: Option<&LanguageIdentifier>,
ftl_folder: &Path, ftl_folder: &Path,
locales: &[LanguageIdentifier], locales: &[LanguageIdentifier],
@ -236,14 +234,20 @@ impl I18n {
log: Logger, log: Logger,
) -> Self { ) -> Self {
let ftl_folder = ftl_folder.into(); let ftl_folder = ftl_folder.into();
let mut langs = vec![]; let mut langs = vec![];
let mut bundles = Vec::with_capacity(locale_codes.len() + 1); let mut bundles = Vec::with_capacity(locale_codes.len() + 1);
let mut resource_text = vec![];
for code in locale_codes { for code in locale_codes {
let code = code.as_ref(); let code = code.as_ref();
if let Ok(lang) = code.parse::<LanguageIdentifier>() { if let Ok(lang) = code.parse::<LanguageIdentifier>() {
langs.push(lang.clone()); 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 // add fallback date/time
@ -255,29 +259,27 @@ impl I18n {
if let Some(bundle) = if let Some(bundle) =
get_bundle_with_extra(text, Some(lang), &ftl_folder, &langs, &log) get_bundle_with_extra(text, Some(lang), &ftl_folder, &langs, &log)
{ {
resource_text.push(text);
bundles.push(bundle); bundles.push(bundle);
} else { } else {
error!(log, "Failed to create bundle for {:?}", lang.language()) 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 // add English templates
let template_text = ftl_template_text();
let template_bundle = 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); bundles.push(template_bundle);
Self { Self {
inner: Arc::new(Mutex::new(I18nInner { bundles })), inner: Arc::new(Mutex::new(I18nInner {
bundles,
langs,
resource_text,
})),
log, log,
} }
} }
@ -320,12 +322,23 @@ impl I18n {
// return the key name if it was missing // return the key name if it was missing
key.to_string().into() 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 { struct I18nInner {
// bundles in preferred language order, with template English as the // bundles in preferred language order, with template English as the
// last element // last element
bundles: Vec<FluentBundle<FluentResource>>, 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]) { 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)] #[cfg(test)]
mod test { mod test {
use crate::i18n::NumberFormatter; use crate::i18n::NumberFormatter;

View file

@ -119,6 +119,7 @@ fn want_release_gil(method: u32) -> bool {
BackendMethod::CountsForDeckToday => true, BackendMethod::CountsForDeckToday => true,
BackendMethod::CardStats => true, BackendMethod::CardStats => true,
BackendMethod::Graphs => true, BackendMethod::Graphs => true,
BackendMethod::I18nResources => false,
} }
} else { } else {
false false

View file

@ -49,6 +49,7 @@
"dev": "webpack-dev-server" "dev": "webpack-dev-server"
}, },
"dependencies": { "dependencies": {
"@fluent/bundle": "^0.15.1",
"d3-array": "^2.4.0", "d3-array": "^2.4.0",
"d3-axis": "^1.0.12", "d3-axis": "^1.0.12",
"d3-scale": "^3.2.1", "d3-scale": "^3.2.1",

View file

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

View file

@ -1,17 +1,24 @@
<script lang="typescript"> <script lang="typescript">
import { gatherData, TodayData } from "./today"; import { gatherData, TodayData } from "./today";
import { studiedToday } from "../time";
import pb from "../backend/proto"; import pb from "../backend/proto";
import { I18n } from "../i18n";
export let sourceData: pb.BackendProto.GraphsOut | null = null; export let sourceData: pb.BackendProto.GraphsOut | null = null;
export let i18n: I18n;
let todayData: TodayData | null = null; let todayData: TodayData | null = null;
$: if (sourceData) { $: if (sourceData) {
console.log("gathering data"); console.log("gathering data");
todayData = gatherData(sourceData); todayData = gatherData(sourceData, i18n);
} }
</script> </script>
<div class="graph"> {#if todayData}
<div class="graph">
<h1>Today</h1> <h1>Today</h1>
<div>{todayData.studiedToday}</div>
{JSON.stringify(todayData)} {JSON.stringify(todayData)}
</div> </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 // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import pb from "../backend/proto"; import pb from "../backend/proto";
import { studiedToday } from "../time";
import { I18n } from "../i18n";
export interface TodayData { export interface TodayData {
answerCount: number;
answerMillis: number;
correctCount: number; correctCount: number;
matureCorrect: number; matureCorrect: number;
matureCount: number; matureCount: number;
@ -13,11 +13,13 @@ export interface TodayData {
reviewCount: number; reviewCount: number;
relearnCount: number; relearnCount: number;
earlyReviewCount: number; earlyReviewCount: number;
studiedToday: string;
} }
const ReviewKind = pb.BackendProto.RevlogEntry.ReviewKind; 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 answerCount = 0;
let answerMillis = 0; let answerMillis = 0;
let correctCount = 0; let correctCount = 0;
@ -69,9 +71,10 @@ export function gatherData(data: pb.BackendProto.GraphsOut): TodayData {
} }
} }
const studiedTodayText = studiedToday(i18n, answerCount, answerMillis / 1000);
return { return {
answerCount, studiedToday: studiedTodayText,
answerMillis,
correctCount, correctCount,
matureCorrect, matureCorrect,
matureCount, 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 = { module.exports = {
entry: { entry: {
graphs: ["./src/stats/GraphsPage.svelte"], graphs: ["./src/stats/graphs-bootstrap.ts"],
}, },
output: { output: {
library: "anki", library: "anki",