diff --git a/ts/.prettierignore b/ts/.prettierignore index ac995e48b..339fa65b6 100644 --- a/ts/.prettierignore +++ b/ts/.prettierignore @@ -1,4 +1,5 @@ licenses.json vendor -*.svelte.d.ts +lib/translate.ts +lib/i18n/modules.ts backend_proto.d.ts diff --git a/ts/change-notetype/ChangeNotetypePage.svelte b/ts/change-notetype/ChangeNotetypePage.svelte index 878114145..03dc13305 100644 --- a/ts/change-notetype/ChangeNotetypePage.svelte +++ b/ts/change-notetype/ChangeNotetypePage.svelte @@ -3,7 +3,7 @@ Copyright: Ankitects Pty Ltd and contributors License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html --> diff --git a/ts/graphs/EaseGraph.svelte b/ts/graphs/EaseGraph.svelte index 2a2f0ceae..de8568311 100644 --- a/ts/graphs/EaseGraph.svelte +++ b/ts/graphs/EaseGraph.svelte @@ -4,10 +4,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html --> diff --git a/ts/graphs/RangeBox.svelte b/ts/graphs/RangeBox.svelte index b4285beb5..10bc3e936 100644 --- a/ts/graphs/RangeBox.svelte +++ b/ts/graphs/RangeBox.svelte @@ -3,11 +3,10 @@ Copyright: Ankitects Pty Ltd and contributors License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -->
- +
{#each tableData as { label, value }} diff --git a/ts/graphs/added.ts b/ts/graphs/added.ts index db4877c85..dca8ad566 100644 --- a/ts/graphs/added.ts +++ b/ts/graphs/added.ts @@ -19,10 +19,10 @@ import { import type { Bin } from "d3"; import type { HistogramData } from "./histogram-graph"; +import * as tr from "../lib/ftl"; import { dayLabel } from "../lib/time"; import { GraphRange } from "./graph-helpers"; import type { TableDatum, SearchDispatch } from "./graph-helpers"; -import * as tr from "../lib/i18n"; export interface GraphData { daysAdded: number[]; diff --git a/ts/graphs/buttons.ts b/ts/graphs/buttons.ts index 3c77166e6..10ae799a9 100644 --- a/ts/graphs/buttons.ts +++ b/ts/graphs/buttons.ts @@ -6,8 +6,6 @@ @typescript-eslint/no-explicit-any: "off", */ -import { Stats } from "../lib/proto"; - import { interpolateRdYlGn, select, @@ -19,6 +17,8 @@ import { axisLeft, sum, } from "d3"; +import { Stats } from "../lib/proto"; +import * as tr from "../lib/ftl"; import { showTooltip, hideTooltip } from "./tooltip"; import { GraphBounds, @@ -26,7 +26,6 @@ import { GraphRange, millisecondCutoffForRange, } from "./graph-helpers"; -import * as tr from "../lib/i18n"; type ButtonCounts = [number, number, number, number]; diff --git a/ts/graphs/calendar.ts b/ts/graphs/calendar.ts index 3bea00fbb..4797ab0a9 100644 --- a/ts/graphs/calendar.ts +++ b/ts/graphs/calendar.ts @@ -29,8 +29,8 @@ import { SearchDispatch, } from "./graph-helpers"; import { clickableClass } from "./graph-styles"; -import { i18n } from "../lib/i18n"; -import * as tr from "../lib/i18n"; +import { weekdayLabel, toLocaleString } from "../lib/i18n"; +import * as tr from "../lib/ftl"; export interface GraphData { // indexed by day, where day is relative to today @@ -155,7 +155,7 @@ export function renderCalendar( .interpolator((n) => interpolateBlues(cappedRange(n)!)); function tooltipText(d: DayDatum): string { - const date = d.date.toLocaleString(i18n.langs, { + const date = toLocaleString(d.date, { weekday: "long", year: "numeric", month: "long", @@ -172,7 +172,7 @@ export function renderCalendar( .selectAll("text") .data(sourceData.weekdayLabels) .join("text") - .text((d: number) => i18n.weekdayLabel(d)) + .text((d: number) => weekdayLabel(d)) .attr("width", x(-1)! - 2) .attr("height", height - 2) .attr("x", x(1)! - 3) diff --git a/ts/graphs/card-counts.ts b/ts/graphs/card-counts.ts index 0d2f0faf7..7897982af 100644 --- a/ts/graphs/card-counts.ts +++ b/ts/graphs/card-counts.ts @@ -6,6 +6,7 @@ @typescript-eslint/no-explicit-any: "off", */ +import * as tr from "../lib/ftl"; import { CardQueue, CardType } from "../lib/cards"; import type { Stats, Cards } from "../lib/proto"; import { @@ -22,8 +23,6 @@ import { } from "d3"; import type { GraphBounds } from "./graph-helpers"; -import * as tr from "../lib/i18n"; - type Count = [string, number, boolean, string]; export interface GraphData { title: string; diff --git a/ts/graphs/ease.ts b/ts/graphs/ease.ts index 5b28ee58d..1ab829900 100644 --- a/ts/graphs/ease.ts +++ b/ts/graphs/ease.ts @@ -16,11 +16,10 @@ import { interpolateRdYlGn, } from "d3"; import type { Bin, ScaleLinear } from "d3"; +import * as tr from "../lib/ftl"; import { CardType } from "../lib/cards"; import type { HistogramData } from "./histogram-graph"; - import type { TableDatum, SearchDispatch } from "./graph-helpers"; -import * as tr from "../lib/i18n"; export interface GraphData { eases: number[]; diff --git a/ts/graphs/future-due.ts b/ts/graphs/future-due.ts index 67d5ba0ed..2c132fea5 100644 --- a/ts/graphs/future-due.ts +++ b/ts/graphs/future-due.ts @@ -17,13 +17,13 @@ import { interpolateGreens, } from "d3"; import type { Bin } from "d3"; +import * as tr from "../lib/ftl"; import { CardQueue } from "../lib/cards"; import type { HistogramData } from "./histogram-graph"; import { dayLabel } from "../lib/time"; import { GraphRange } from "./graph-helpers"; import type { TableDatum, SearchDispatch } from "./graph-helpers"; -import * as tr from "../lib/i18n"; export interface GraphData { dueCounts: Map; diff --git a/ts/graphs/hours.ts b/ts/graphs/hours.ts index 1508c6928..3724bb301 100644 --- a/ts/graphs/hours.ts +++ b/ts/graphs/hours.ts @@ -21,6 +21,7 @@ import { curveBasis, } from "d3"; +import * as tr from "../lib/ftl"; import { showTooltip, hideTooltip } from "./tooltip"; import { GraphBounds, @@ -29,7 +30,6 @@ import { millisecondCutoffForRange, } from "./graph-helpers"; import { oddTickClass } from "./graph-styles"; -import * as tr from "../lib/i18n"; interface Hour { hour: number; diff --git a/ts/graphs/intervals.ts b/ts/graphs/intervals.ts index 22372a03c..11c796659 100644 --- a/ts/graphs/intervals.ts +++ b/ts/graphs/intervals.ts @@ -18,12 +18,12 @@ import { interpolateBlues, } from "d3"; import type { Bin } from "d3"; +import * as tr from "../lib/ftl"; +import { timeSpan } from "../lib/time"; import { CardType } from "../lib/cards"; import type { HistogramData } from "./histogram-graph"; import type { TableDatum, SearchDispatch } from "./graph-helpers"; -import { timeSpan } from "../lib/time"; -import * as tr from "../lib/i18n"; export interface IntervalGraphData { intervals: number[]; diff --git a/ts/graphs/reviews.ts b/ts/graphs/reviews.ts index f31f7e161..00a6a2617 100644 --- a/ts/graphs/reviews.ts +++ b/ts/graphs/reviews.ts @@ -32,10 +32,10 @@ import { } from "d3"; import type { Bin } from "d3"; +import * as tr from "../lib/ftl"; import type { TableDatum } from "./graph-helpers"; import { GraphBounds, setDataAvailable, GraphRange } from "./graph-helpers"; import { showTooltip, hideTooltip } from "./tooltip"; -import * as tr from "../lib/i18n"; interface Reviews { learn: number; diff --git a/ts/graphs/today.ts b/ts/graphs/today.ts index 07e5a12e7..568e536dc 100644 --- a/ts/graphs/today.ts +++ b/ts/graphs/today.ts @@ -3,8 +3,7 @@ import { Stats } from "../lib/proto"; import { studiedToday } from "../lib/time"; - -import * as tr from "../lib/i18n"; +import * as tr from "../lib/ftl"; export interface TodayData { title: string; diff --git a/ts/lib/BUILD.bazel b/ts/lib/BUILD.bazel index 10811be31..14bb8df24 100644 --- a/ts/lib/BUILD.bazel +++ b/ts/lib/BUILD.bazel @@ -1,17 +1,11 @@ +load("@rules_python//python:defs.bzl", "py_binary") load("//ts:prettier.bzl", "prettier_test") load("//ts:eslint.bzl", "eslint_test") load("//ts:protobuf.bzl", "protobufjs_library") load("//ts:typescript.bzl", "typescript") -load("@rules_python//python:defs.bzl", "py_binary") load("@py_deps//:requirements.bzl", "requirement") load("//ts:jest.bzl", "jest_test") -protobufjs_library( - name = "backend_proto", - proto = "//proto:backend_proto_lib", - visibility = ["//visibility:public"], -) - py_binary( name = "genfluent", srcs = [ @@ -22,21 +16,33 @@ py_binary( requirement("stringcase"), ], ) +_i18n_generated = [ + "ftl.ts", + "i18n/modules.ts", +] genrule( name = "fluent_gen", - outs = ["i18n.ts"], - cmd = "$(location genfluent) $(location //rslib/i18n:strings.json) $@", + outs = _i18n_generated, + cmd = "$(location genfluent) $(location //rslib/i18n:strings.json) $(OUTS)", tools = [ "genfluent", "//rslib/i18n:strings.json", ], ) +protobufjs_library( + name = "backend_proto", + proto = "//proto:backend_proto_lib", + visibility = ["//visibility:public"], +) + typescript( name = "lib", generated = [ - ":i18n.ts", + ":backend_proto.d.ts", + ":ftl.ts", + ":i18n/modules.ts", ], deps = [ ":backend_proto", diff --git a/ts/lib/genfluent.py b/ts/lib/genfluent.py index 3944aad5f..075707ed2 100644 --- a/ts/lib/genfluent.py +++ b/ts/lib/genfluent.py @@ -7,7 +7,7 @@ from typing import List, Literal, TypedDict import stringcase -strings_json, outfile = sys.argv[1:] +strings_json, translate_out, modules_out = sys.argv[1:] modules = json.load(open(strings_json, encoding="utf8")) @@ -17,10 +17,12 @@ class Variable(TypedDict): def methods() -> str: - out = [ - 'import { i18n } from "./i18n_helpers";', - 'export { i18n, setupI18n } from "./i18n_helpers";', - ] + out = [ """import type { FluentVariable } from "@fluent/bundle"; +import { getMessage } from "./i18n"; + +function translate(key: string, args: Record = {}): string { + return getMessage(key, args) ?? `missing key: ${key}`; +}""" ] for module in modules: for translation in module["translations"]: key = stringcase.camelcase(translation["key"].replace("-", "_")) @@ -31,7 +33,7 @@ def methods() -> str: f""" /** {doc} */ export function {key}({arg_types}): string {{ - return i18n.translate("{translation["key"]}"{args}) + return translate("{translation["key"]}"{args}) }} """ ) @@ -102,17 +104,17 @@ def module_names() -> str: return buf -out = "" - -out += methods() -out += module_names() - -open(outfile, "wb").write( - ( - """// Copyright: Ankitects Pty Ltd and contributors +def write(outfile, out) -> None: + open(outfile, "wb").write( + ( + f"""// Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html """ - + out - ).encode("utf8") -) + + out + ).encode("utf8") + ) + + +write(translate_out, str(methods())) +write(modules_out, str(module_names())) diff --git a/ts/lib/i18n/bundles.ts b/ts/lib/i18n/bundles.ts new file mode 100644 index 000000000..d83860ea3 --- /dev/null +++ b/ts/lib/i18n/bundles.ts @@ -0,0 +1,46 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import { FluentNumber } from "@fluent/bundle"; +import type { FluentBundle, FluentVariable } from "@fluent/bundle"; + +let bundles: FluentBundle[] = []; + +export function setBundles(newBundles: FluentBundle[]): void { + bundles = newBundles; +} + +export function firstLanguage(): string { + return bundles[0].locales[0]; +} + +function toFluentNumber(num: number): FluentNumber { + return new FluentNumber(num, { + maximumFractionDigits: 2, + }); +} + +function formatArgs( + args: Record +): Record { + return Object.fromEntries( + Object.entries(args).map(([key, value]) => [ + key, + typeof value === "number" ? toFluentNumber(value) : value, + ]) + ); +} + +export function getMessage( + key: string, + args: Record = {} +): string | null { + for (const bundle of bundles) { + const msg = bundle.getMessage(key); + if (msg && msg.value) { + return bundle.formatPattern(msg.value, formatArgs(args)); + } + } + + return null; +} diff --git a/ts/lib/i18n/index.ts b/ts/lib/i18n/index.ts new file mode 100644 index 000000000..d547a93c0 --- /dev/null +++ b/ts/lib/i18n/index.ts @@ -0,0 +1,6 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +export * from "./bundles"; +export * from "./modules"; +export * from "./utils"; diff --git a/ts/lib/i18n/utils.ts b/ts/lib/i18n/utils.ts new file mode 100644 index 000000000..9ff8d9e4f --- /dev/null +++ b/ts/lib/i18n/utils.ts @@ -0,0 +1,87 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import "intl-pluralrules"; +import { FluentBundle, FluentResource } from "@fluent/bundle"; + +import { firstLanguage, setBundles } from "./bundles"; +import type { ModuleName } from "./modules"; + +export function supportsVerticalText(): boolean { + const firstLang = firstLanguage(); + return ( + firstLang.startsWith("ja") || + firstLang.startsWith("zh") || + firstLang.startsWith("ko") + ); +} + +export function direction(): string { + const firstLang = firstLanguage(); + if ( + firstLang.startsWith("ar") || + firstLang.startsWith("he") || + firstLang.startsWith("fa") + ) { + return "rtl"; + } else { + return "ltr"; + } +} + +export function weekdayLabel(n: number): string { + const firstLang = firstLanguage(); + const now = new Date(); + const daysFromToday = -now.getDay() + n; + const desiredDay = new Date(now.getTime() + daysFromToday * 86_400_000); + return desiredDay.toLocaleDateString(firstLang, { + weekday: "narrow", + }); +} + +let langs: string[] = []; + +export function toLocaleString( + date: Date, + options?: Intl.DateTimeFormatOptions +): string { + return date.toLocaleDateString(langs, options); +} + +export function localeCompare( + first: string, + second: string, + options?: Intl.CollatorOptions +): number { + return first.localeCompare(second, langs, options); +} + +/// Treat text like HTML, merging multiple spaces and converting +/// newlines to spaces. +export function withCollapsedWhitespace(s: string): string { + return s.replace(/\s+/g, " "); +} + +export async function setupI18n(args: { modules: ModuleName[] }): Promise { + const resp = await fetch("/_anki/i18nResources", { + method: "POST", + body: JSON.stringify(args), + }); + if (!resp.ok) { + throw Error(`unexpected reply: ${resp.statusText}`); + } + const json = await resp.json(); + + const newBundles: FluentBundle[] = []; + for (const i in json.resources) { + const text = json.resources[i]; + const lang = json.langs[i]; + const bundle = new FluentBundle([lang, "en-US"]); + const resource = new FluentResource(text); + bundle.addResource(resource); + newBundles.push(bundle); + } + + setBundles(newBundles); + langs = json.langs; +} diff --git a/ts/lib/i18n_helpers.ts b/ts/lib/i18n_helpers.ts deleted file mode 100644 index 5852d6444..000000000 --- a/ts/lib/i18n_helpers.ts +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright: Ankitects Pty Ltd and contributors -// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -// An i18n singleton and setupI18n is re-exported via the generated i18n.ts file, -// so you should not need to access this file directly. - -import "intl-pluralrules"; -import { FluentBundle, FluentResource, FluentNumber } from "@fluent/bundle"; - -type RecordVal = number | string | FluentNumber; - -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] as number, { - maximumFractionDigits: 2, - }); - } - } -} - -export class I18n { - bundles: FluentBundle[] = []; - langs: string[] = []; - - translate(key: string, args?: Record): string { - formatNumbers(args); - 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}`; - } - - supportsVerticalText(): boolean { - const firstLang = this.bundles[0].locales[0]; - return ( - firstLang.startsWith("ja") || - firstLang.startsWith("zh") || - firstLang.startsWith("ko") - ); - } - - direction(): string { - const firstLang = this.bundles[0].locales[0]; - if ( - firstLang.startsWith("ar") || - firstLang.startsWith("he") || - firstLang.startsWith("fa") - ) { - return "rtl"; - } else { - return "ltr"; - } - } - - weekdayLabel(n: number): string { - const firstLang = this.bundles[0].locales[0]; - const now = new Date(); - const daysFromToday = -now.getDay() + n; - const desiredDay = new Date(now.getTime() + daysFromToday * 86_400_000); - return desiredDay.toLocaleDateString(firstLang, { - weekday: "narrow", - }); - } - - /// Treat text like HTML, merging multiple spaces and converting - /// newlines to spaces. - withCollapsedWhitespace(s: string): string { - return s.replace(/\s+/g, " "); - } -} - -// global singleton -export const i18n = new I18n(); - -import type { ModuleName } from "./i18n"; - -export async function setupI18n(args: { modules: ModuleName[] }): Promise { - const resp = await fetch("/_anki/i18nResources", { - method: "POST", - body: JSON.stringify(args), - }); - if (!resp.ok) { - throw Error(`unexpected reply: ${resp.statusText}`); - } - const json = await resp.json(); - - i18n.bundles = []; - for (const i in json.resources) { - const text = json.resources[i]; - const lang = json.langs[i]; - const bundle = new FluentBundle([lang, "en-US"]); - const resource = new FluentResource(text); - bundle.addResource(resource); - i18n.bundles.push(bundle); - } - i18n.langs = json.langs; -} diff --git a/ts/lib/keys.ts b/ts/lib/keys.ts index ef7bcad15..8de5acd76 100644 --- a/ts/lib/keys.ts +++ b/ts/lib/keys.ts @@ -1,7 +1,7 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -import * as tr from "./i18n"; +import * as tr from "./ftl"; import { isApplePlatform } from "./platform"; // those are the modifiers that Anki works with diff --git a/ts/lib/proto.ts b/ts/lib/proto.ts index 08b1a8853..e182f6c10 100644 --- a/ts/lib/proto.ts +++ b/ts/lib/proto.ts @@ -2,6 +2,7 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { anki } from "./backend_proto"; + import Cards = anki.cards; import DeckConfig = anki.deckconfig; import Notetypes = anki.notetypes; diff --git a/ts/lib/time.ts b/ts/lib/time.ts index c59e4fd8a..18d9263e8 100644 --- a/ts/lib/time.ts +++ b/ts/lib/time.ts @@ -1,7 +1,7 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -import * as tr from "./i18n"; +import * as tr from "./ftl"; export const SECOND = 1.0; export const MINUTE = 60.0 * SECOND; diff --git a/ts/lib/tsconfig.json b/ts/lib/tsconfig.json index 3614760cc..39f73a570 100644 --- a/ts/lib/tsconfig.json +++ b/ts/lib/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../tsconfig.json", - "include": ["*", "../../bazel-bin/ts/lib/*"], + "include": ["*", "i18n/*"], "references": [], "compilerOptions": { "types": ["jest"] diff --git a/ts/tsconfig.json b/ts/tsconfig.json index 7c93dc9dd..636dcbbf9 100644 --- a/ts/tsconfig.json +++ b/ts/tsconfig.json @@ -17,14 +17,15 @@ "declaration": true, "composite": true, "target": "es6", - "module": "es6", + "module": "es2020", "lib": [ "es2017", "es2018.intl", - "es2019.array", "es2018.promise", - "es2020.promise", + "es2019.array", + "es2019.object", "es2019.string", + "es2020.promise", "dom", "dom.iterable" ], diff --git a/ts/typescript.bzl b/ts/typescript.bzl index 26dce9894..69ecadc09 100644 --- a/ts/typescript.bzl +++ b/ts/typescript.bzl @@ -4,12 +4,13 @@ load("@build_bazel_rules_nodejs//:index.bzl", "copy_to_bin", "js_library") def typescript( name, srcs = None, + exclude = [], generated = [], tsconfig = "tsconfig.json", visibility = ["//visibility:public"], **kwargs): if not srcs: - srcs = native.glob(["**/*.ts"]) + srcs = native.glob(["**/*.ts"], exclude = exclude) # all tsconfig files must be in the bazel-out folder copy_to_bin(
{label}: