diff --git a/packages/taler-util/package.json b/packages/taler-util/package.json index a1c769e8f..977a35c6f 100644 --- a/packages/taler-util/package.json +++ b/packages/taler-util/package.json @@ -36,6 +36,7 @@ "typescript": "^4.2.3" }, "dependencies": { + "jed": "^1.1.1", "tslib": "^2.1.0" }, "ava": { diff --git a/packages/taler-util/src/i18n.ts b/packages/taler-util/src/i18n.ts new file mode 100644 index 000000000..4253eb227 --- /dev/null +++ b/packages/taler-util/src/i18n.ts @@ -0,0 +1,147 @@ +// @ts-ignore: no type decl for this library +import * as jedLib from "jed"; +import { Logger } from "./logging"; + +const logger = new Logger("i18n/index.ts"); + +export let jed: any = undefined; + +/** + * Set up jed library for internationalization, + * based on browser language settings. + */ +export function setupI18n(lang: string, strings: { [s: string]: any }): any { + lang = lang.replace("_", "-"); + + if (!strings[lang]) { + lang = "en-US"; + logger.warn(`language ${lang} not found, defaulting to english`); + } + debugger + jed = new jedLib.Jed(strings[lang]); +} + +/** + * Use different translations for testing. Should not be used outside + * of test cases. + */ +export function internalSetStrings(langStrings: any): void { + jed = new jedLib.Jed(langStrings); +} + +/** + * Convert template strings to a msgid + */ +function toI18nString(stringSeq: ReadonlyArray): string { + let s = ""; + for (let i = 0; i < stringSeq.length; i++) { + s += stringSeq[i]; + if (i < stringSeq.length - 1) { + s += `%${i + 1}$s`; + } + } + return s; +} + +/** + * Internationalize a string template with arbitrary serialized values. + */ +export function str(stringSeq: TemplateStringsArray, ...values: any[]): string { + const s = toI18nString(stringSeq); + const tr = jed + .translate(s) + .ifPlural(1, s) + .fetch(...values); + return tr; +} + +/** + * Internationalize a string template without serializing + */ +export function translate(stringSeq: TemplateStringsArray, ...values: any[]): any[] { + const s = toI18nString(stringSeq); + if (!s) return [] + const translation: string = jed.ngettext(s, s, 1); + return replacePlaceholderWithValues(translation, values) +} + +/** + * Internationalize a string template without serializing + */ +export function Translate({ children, ...rest }: { children: any }): any { + const c = [].concat(children); + const s = stringifyArray(c); + if (!s) return [] + const translation: string = jed.ngettext(s, s, 1); + return replacePlaceholderWithValues(translation, c) +} + +/** + * Get an internationalized string (based on the globally set, current language) + * from a JSON object. Fall back to the default language of the JSON object + * if no match exists. + */ +export function getJsonI18n( + obj: Record, + key: K, +): string { + return obj[key]; +} + +export function getTranslatedArray(array: Array) { + const s = stringifyArray(array); + const translation: string = jed.ngettext(s, s, 1); + return replacePlaceholderWithValues(translation, array); +} + + +function replacePlaceholderWithValues( + translation: string, + childArray: Array, +): Array { + const tr = translation.split(/%(\d+)\$s/); + // const childArray = toChildArray(children); + // Merge consecutive string children. + const placeholderChildren = []; + for (let i = 0; i < childArray.length; i++) { + const x = childArray[i]; + if (x === undefined) { + continue; + } else if (typeof x === "string") { + continue; + } else { + placeholderChildren.push(x); + } + } + const result = []; + for (let i = 0; i < tr.length; i++) { + if (i % 2 == 0) { + // Text + result.push(tr[i]); + } else { + const childIdx = Number.parseInt(tr[i]) - 1; + result.push(placeholderChildren[childIdx]); + } + } + return result; +} + +function stringifyArray(children: Array): string { + let n = 1; + const ss = children.map((c) => { + if (typeof c === "string") { + return c; + } + return `%${n++}$s`; + }); + const s = ss.join("").replace(/ +/g, " ").trim(); + console.log("translation lookup", JSON.stringify(s)); + return s; +} + +export const i18n = { + str, + Translate, + translate +} + diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts index 3416c7d12..25a24fa18 100644 --- a/packages/taler-util/src/index.ts +++ b/packages/taler-util/src/index.ts @@ -16,4 +16,6 @@ export * from "./talerTypes.js"; export * from "./taleruri.js"; export * from "./time.js"; export * from "./transactionsTypes.js"; -export * from "./walletTypes.js"; \ No newline at end of file +export * from "./walletTypes.js"; +export * from "./i18n.js"; +export * from "./logging.js"; \ No newline at end of file diff --git a/packages/taler-util/src/logging.ts b/packages/taler-util/src/logging.ts new file mode 100644 index 000000000..4f48e24da --- /dev/null +++ b/packages/taler-util/src/logging.ts @@ -0,0 +1,100 @@ +/* + This file is part of TALER + (C) 2019 GNUnet e.V. + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see + */ + +/** + * Check if we are running under nodejs. + */ + +const isNode = + typeof process !== "undefined" && typeof process.release !== "undefined" && process.release.name === "node"; + +function writeNodeLog( + message: any, + tag: string, + level: string, + args: any[], +): void { + try { + process.stderr.write(`${new Date().toISOString()} ${tag} ${level} `); + process.stderr.write(`${message}`); + if (args.length != 0) { + process.stderr.write(" "); + process.stderr.write(JSON.stringify(args, undefined, 2)); + } + process.stderr.write("\n"); + } catch (e) { + // This can happen when we're trying to log something that doesn't want to be + // converted to a string. + process.stderr.write(`${new Date().toISOString()} (logger) FATAL `); + if (e instanceof Error) { + process.stderr.write("failed to write log: "); + process.stderr.write(e.message); + } + process.stderr.write("\n"); + } +} + +/** + * Logger that writes to stderr when running under node, + * and uses the corresponding console.* method to log in the browser. + */ +export class Logger { + constructor(private tag: string) {} + + info(message: string, ...args: any[]): void { + if (isNode) { + writeNodeLog(message, this.tag, "INFO", args); + } else { + console.info( + `${new Date().toISOString()} ${this.tag} INFO ` + message, + ...args, + ); + } + } + + warn(message: string, ...args: any[]): void { + if (isNode) { + writeNodeLog(message, this.tag, "WARN", args); + } else { + console.warn( + `${new Date().toISOString()} ${this.tag} INFO ` + message, + ...args, + ); + } + } + + error(message: string, ...args: any[]): void { + if (isNode) { + writeNodeLog(message, this.tag, "ERROR", args); + } else { + console.info( + `${new Date().toISOString()} ${this.tag} ERROR ` + message, + ...args, + ); + } + } + + trace(message: any, ...args: any[]): void { + if (isNode) { + writeNodeLog(message, this.tag, "TRACE", args); + } else { + console.info( + `${new Date().toISOString()} ${this.tag} TRACE ` + message, + ...args, + ); + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cf013b82..10198d6af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,11 +46,13 @@ importers: '@types/node': ^14.14.22 ava: ^3.15.0 esbuild: ^0.9.2 + jed: ^1.1.1 prettier: ^2.2.1 rimraf: ^3.0.2 tslib: ^2.1.0 typescript: ^4.2.3 dependencies: + jed: 1.1.1 tslib: 2.1.0 devDependencies: '@types/node': 14.14.34 @@ -11583,7 +11585,6 @@ packages: /jed/1.1.1: resolution: {integrity: sha1-elSbvZ/+FYWwzQoZHiAwVb7ldLQ=} - dev: true /jest-changed-files/26.6.2: resolution: {integrity: sha512-fDS7szLcY9sCtIip8Fjry9oGf3I2ht/QT21bAHm5Dmf0mD4X3ReNUf17y+bO6fR8WgbIZTlbyG1ak/53cbRzKQ==}