mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 22:12:21 -04:00
get i18n working in typescript
This commit is contained in:
parent
0f1f80aebc
commit
41d77b0255
14 changed files with 300 additions and 101 deletions
|
@ -183,6 +183,7 @@ service BackendService {
|
|||
|
||||
rpc TranslateString (TranslateStringIn) returns (String);
|
||||
rpc FormatTimespan (FormatTimespanIn) returns (String);
|
||||
rpc I18nResources (Empty) returns (Json);
|
||||
|
||||
// tags
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
//-------------------------------------------------------------------
|
||||
|
||||
|
|
|
@ -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,13 +65,12 @@ 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() {
|
||||
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"),
|
||||
|
@ -134,9 +134,7 @@ fn ftl_localized_text(lang: &LanguageIdentifier) -> Option<String> {
|
|||
"uk" => include_str!("ftl/uk.ftl"),
|
||||
"vi" => include_str!("ftl/vi.ftl"),
|
||||
_ => return None,
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
57
ts/src/i18n.ts
Normal 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;
|
||||
}
|
|
@ -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} />
|
||||
|
|
|
@ -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">
|
||||
{#if todayData}
|
||||
<div class="graph">
|
||||
<h1>Today</h1>
|
||||
|
||||
<div>{todayData.studiedToday}</div>
|
||||
{JSON.stringify(todayData)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
14
ts/src/stats/graphs-bootstrap.ts
Normal file
14
ts/src/stats/graphs-bootstrap.ts
Normal 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 },
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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
89
ts/src/time.ts
Normal 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,
|
||||
});
|
||||
}
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue