diff --git a/.cargo/config.toml b/.cargo/config.toml index 744ad63a3..c2cf0e821 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,5 +1,7 @@ [env] STRINGS_JSON = { value = "out/rslib/i18n/strings.json", relative = true } +STRINGS_JS = { value = "out/ts/lib/ftl.js", relative = true } +STRINGS_DTS = { value = "out/ts/lib/ftl.d.ts", relative = true } DESCRIPTORS_BIN = { value = "out/rslib/proto/descriptors.bin", relative = true } # build script will append .exe if necessary PROTOC = { value = "out/extracted/protoc/bin/protoc", relative = true } diff --git a/Cargo.lock b/Cargo.lock index 5ee5495e6..07f938928 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,6 +182,7 @@ dependencies = [ "fluent-syntax", "inflections", "intl-memoizer", + "itertools 0.11.0", "num-format", "phf 0.11.2", "serde", diff --git a/build/configure/src/python.rs b/build/configure/src/python.rs index 1d1a1bc7b..4a7f2f55d 100644 --- a/build/configure/src/python.rs +++ b/build/configure/src/python.rs @@ -161,7 +161,6 @@ pub fn check_python(build: &mut Build) -> Result<()> { PythonTypecheck { folders: &[ "pylib", - "ts/lib", "qt/aqt", "qt/tools", "out/pylib/anki", diff --git a/build/configure/src/web.rs b/build/configure/src/web.rs index 4bec355e1..1800bcc3d 100644 --- a/build/configure/src/web.rs +++ b/build/configure/src/web.rs @@ -3,7 +3,6 @@ use anyhow::Result; use ninja_gen::action::BuildAction; -use ninja_gen::command::RunCommand; use ninja_gen::glob; use ninja_gen::hashmap; use ninja_gen::input::BuildInput; @@ -115,26 +114,7 @@ fn setup_node(build: &mut Build) -> Result<()> { } fn build_and_check_tslib(build: &mut Build) -> Result<()> { - build.add_action( - "ts:lib:i18n", - RunCommand { - command: ":pyenv:bin", - args: "$script $strings $out", - inputs: hashmap! { - "script" => inputs!["ts/lib/genfluent.py"], - "strings" => inputs![":rslib:i18n:strings.json"], - "" => inputs!["pylib/anki/_vendor/stringcase.py"] - }, - outputs: hashmap! { - "out" => vec![ - "ts/lib/ftl.js", - "ts/lib/ftl.d.ts", - "ts/lib/i18n/modules.js", - "ts/lib/i18n/modules.d.ts" - ] - }, - }, - )?; + build.add_dependency("ts:lib:i18n", ":rslib:i18n".into()); build.add_action( "ts:lib:proto", GenTypescriptProto { diff --git a/rslib/i18n/Cargo.toml b/rslib/i18n/Cargo.toml index c6105f9c5..e74efc6b2 100644 --- a/rslib/i18n/Cargo.toml +++ b/rslib/i18n/Cargo.toml @@ -22,6 +22,7 @@ serde_json.workspace = true inflections.workspace = true anki_io.workspace = true anyhow.workspace = true +itertools.workspace = true [dependencies] fluent.workspace = true diff --git a/rslib/i18n/build/main.rs b/rslib/i18n/build/main.rs index d3fcfbe1e..7fae2a7f9 100644 --- a/rslib/i18n/build/main.rs +++ b/rslib/i18n/build/main.rs @@ -4,6 +4,7 @@ mod check; mod extract; mod gather; +mod typescript; mod write_strings; use std::path::Path; @@ -25,6 +26,8 @@ fn main() -> Result<()> { let modules = get_modules(&map); write_strings(&map, &modules); + typescript::write_ts_interface(&modules)?; + // write strings.json file to requested path println!("cargo:rerun-if-env-changed=STRINGS_JSON"); if let Some(path) = option_env!("STRINGS_JSON") { diff --git a/rslib/i18n/build/typescript.rs b/rslib/i18n/build/typescript.rs new file mode 100644 index 000000000..26658d2b8 --- /dev/null +++ b/rslib/i18n/build/typescript.rs @@ -0,0 +1,123 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::env; +use std::fmt::Write; +use std::path::PathBuf; + +use anki_io::create_dir_all; +use anki_io::write_file_if_changed; +use anyhow::Result; +use inflections::Inflect; +use itertools::Itertools; + +use crate::extract::Module; +use crate::extract::Variable; +use crate::extract::VariableKind; + +pub fn write_ts_interface(modules: &[Module]) -> Result<()> { + let mut dts_out = header(); + let mut js_out = header(); + write_translate_method(&mut js_out); + dts_out.push_str("export declare const funcs: any;\n"); + + render_module_map(modules, &mut dts_out, &mut js_out); + render_methods(modules, &mut dts_out, &mut js_out); + + if let Ok(path) = env::var("STRINGS_JS") { + let path = PathBuf::from(path); + create_dir_all(path.parent().unwrap())?; + write_file_if_changed(path, js_out)?; + } + if let Ok(path) = env::var("STRINGS_DTS") { + let path = PathBuf::from(path); + create_dir_all(path.parent().unwrap())?; + write_file_if_changed(path, dts_out)?; + } + + Ok(()) +} + +fn render_module_map(modules: &[Module], dts_out: &mut String, js_out: &mut String) { + dts_out.push_str("export declare enum ModuleName {\n"); + js_out.push_str("export const ModuleName = {};\n"); + for module in modules { + let name = &module.name; + let upper = name.to_upper_case(); + writeln!(dts_out, r#" {upper} = "{name}","#).unwrap(); + writeln!(js_out, r#"ModuleName["{upper}"] = "{name}";"#).unwrap(); + } + dts_out.push('}'); +} + +fn render_methods(modules: &[Module], dts_out: &mut String, js_out: &mut String) { + for module in modules { + for translation in &module.translations { + let text = &translation.text; + let key = &translation.key; + let func_name = key.replace('-', "_").to_camel_case(); + let arg_types = get_arg_types(&translation.variables); + let maybe_args = if translation.variables.is_empty() { + "" + } else { + "args" + }; + writeln!( + dts_out, + " +/** {text} */ +export declare function {func_name}({arg_types}): string;", + ) + .unwrap(); + writeln!( + js_out, + r#" +export function {func_name}({maybe_args}) {{ + return translate("{key}", {maybe_args}) +}}"#, + ) + .unwrap(); + } + } +} + +fn write_translate_method(buf: &mut String) { + buf.push_str( + " +// tslib is responsible for injecting getMessage helper in +export const funcs = {}; + +function translate(key, args = {}) { + return funcs.getMessage(key, args) ?? `missing key: ${key}`; +} +", + ); +} + +fn header() -> String { + "// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +" + .to_string() +} + +fn get_arg_types(args: &[Variable]) -> String { + let args = args + .iter() + .map(|arg| format!("{}: {}", arg.name.to_camel_case(), arg_kind(&arg.kind))) + .join(", "); + if args.is_empty() { + "".into() + } else { + format!("args: {{{args}}}",) + } +} + +fn arg_kind(kind: &VariableKind) -> &str { + match kind { + VariableKind::Int | VariableKind::Float => "number", + VariableKind::String => "string", + VariableKind::Any => "number | string", + } +} diff --git a/ts/deck-options/DisplayOrder.svelte b/ts/deck-options/DisplayOrder.svelte index fb686b508..1e676611f 100644 --- a/ts/deck-options/DisplayOrder.svelte +++ b/ts/deck-options/DisplayOrder.svelte @@ -97,11 +97,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html const settings = { newGatherPriority: { title: tr.deckConfigNewGatherPriority(), - help: tr.deckConfigNewGatherPriorityTooltip_2() + currentDeck, + help: tr.deckConfigNewGatherPriorityTooltip2() + currentDeck, }, newCardSortOrder: { title: tr.deckConfigNewCardSortOrder(), - help: tr.deckConfigNewCardSortOrderTooltip_2() + currentDeck, + help: tr.deckConfigNewCardSortOrderTooltip2() + currentDeck, }, newReviewPriority: { title: tr.deckConfigNewReviewPriority(), diff --git a/ts/graphs/RangeBox.svelte b/ts/graphs/RangeBox.svelte index d24f871c3..7f13bd217 100644 --- a/ts/graphs/RangeBox.svelte +++ b/ts/graphs/RangeBox.svelte @@ -59,7 +59,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html $search = displayedSearch; } - const year = tr.statisticsRange_1YearHistory(); + const year = tr.statisticsRange1YearHistory(); const deck = tr.statisticsRangeDeck(); const collection = tr.statisticsRangeCollection(); const searchLabel = tr.statisticsRangeSearch(); diff --git a/ts/lib/genfluent.py b/ts/lib/genfluent.py deleted file mode 100644 index 6aa39e43f..000000000 --- a/ts/lib/genfluent.py +++ /dev/null @@ -1,141 +0,0 @@ -# Copyright: Ankitects Pty Ltd and contributors -# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -import json -import sys -from typing import List, Literal, TypedDict - -sys.path.append("pylib/anki/_vendor") - -import stringcase - -strings_json, ftl_js_path, ftl_dts_path, modules_js_path, modules_dts_path = sys.argv[ - 1: -] -with open(strings_json, encoding="utf8") as f: - modules = json.load(f) - - -class Variable(TypedDict): - name: str - kind: Literal["Any", "Int", "String", "Float"] - - -def write_methods() -> None: - js_out = [ - """ -// tslib is responsible for injecting getMessage helper in -export const funcs = {}; - -function translate(key, args = {}) { - return funcs.getMessage(key, args) ?? `missing key: ${key}`; -} -""" - ] - dts_out = ["export declare const funcs: any;"] - for module in modules: - for translation in module["translations"]: - key = stringcase.camelcase(translation["key"].replace("-", "_")) - arg_types = get_arg_name_and_types(translation["variables"]) - args = get_args(translation["variables"]) - doc = translation["text"] - dts_out.append( - f""" -/** {doc} */ -export declare function {key}({arg_types}): string; -""" - ) - js_out.append( - f""" -export function {key}({"args" if arg_types else ""}) {{ - return translate("{translation["key"]}"{args}) -}} -""" - ) - - write(ftl_dts_path, "\n".join(dts_out) + "\n") - write(ftl_js_path, "\n".join(js_out) + "\n") - - -def write_modules() -> None: - dts_buf = "export declare enum ModuleName {\n" - for module in modules: - name = module["name"] - upper = name.upper() - dts_buf += f' {upper} = "{name}",\n' - dts_buf += "}\n" - js_buf = "export const ModuleName = {};\n" - for module in modules: - name = module["name"] - upper = name.upper() - js_buf += f'ModuleName["{upper}"] = "{name}";\n' - write(modules_dts_path, dts_buf) - write(modules_js_path, js_buf) - - -def get_arg_name_and_types(args: List[Variable]) -> str: - if not args: - return "" - else: - return ( - "args: {" - + ", ".join( - [f"{typescript_arg_name(arg)}: {arg_kind(arg)}" for arg in args] - ) - + "}" - ) - - -def arg_kind(arg: Variable) -> str: - if arg["kind"] in ("Int", "Float"): - return "number" - elif arg["kind"] == "Any": - return "number | string" - else: - return "string" - - -def map_args_to_real_names(args: List[Variable]) -> str: - return ( - "{" - + ", ".join( - [f'"{arg["name"]}": args.{typescript_arg_name(arg)}' for arg in args] - ) - + "}" - ) - - -def get_args(args: List[Variable]) -> str: - if not args: - return "" - else: - for arg in args: - if typescript_arg_name(arg) != arg["name"]: - # we'll need to map variables to their fluent equiv - return ", " + map_args_to_real_names(args) - - # variable names match, reference object instead - return ", args" - - -def typescript_arg_name(arg: Variable) -> str: - name = stringcase.camelcase(arg["name"].replace("-", "_")) - if name == "new": - return "new_" - else: - return name - - -def write(outfile, out) -> None: - with open(outfile, "w", encoding="utf8") as f: - f.write( - f"""// Copyright: Ankitects Pty Ltd and contributors -// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -""" - + out - ) - - -write_methods() -write_modules() diff --git a/ts/lib/i18n/index.ts b/ts/lib/i18n/index.ts index 73457331c..e4fff4b0f 100644 --- a/ts/lib/i18n/index.ts +++ b/ts/lib/i18n/index.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 -export * from "../../../out/ts/lib/i18n/modules"; +export { ModuleName } from "../../../out/ts/lib/ftl"; export * from "./bundles"; export * from "./utils"; // this is an ugly hack to inject code into a generated module diff --git a/ts/lib/i18n/utils.ts b/ts/lib/i18n/utils.ts index 192e2806a..3d1ab047c 100644 --- a/ts/lib/i18n/utils.ts +++ b/ts/lib/i18n/utils.ts @@ -5,9 +5,9 @@ import "intl-pluralrules"; import { FluentBundle, FluentResource } from "@fluent/bundle"; import { i18nResources } from "@tslib/backend"; +import type { ModuleName } from "@tslib/ftl"; import { firstLanguage, setBundles } from "./bundles"; -import type { ModuleName } from "./modules"; export function supportsVerticalText(): boolean { const firstLang = firstLanguage();