diff --git a/README.md b/README.md index 6661fc3..4022932 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ This client will produce `Translators` who are described in typescript by the ty import { I18nServer, I18nClient } from 'omni18n' const server = new I18nServer(myDBinterface) -const client = new I18nClient('en-US', server.condensed) +const client = new I18nClient(['en-US'], server.condensed) const T = client.enter() await client.loaded @@ -38,6 +38,18 @@ The full-stack case will insert the http protocol between `client` and `server`. In interactive mode (using `InteractiveServer`), the DB interface contains modification functions and the server exposes modification function, that will modify the DB but also raise events. In this case, an `InteractiveServer` instance has to be created for every client, with an interface toward the DB and a callback for event raising. +### DB-level + +Two interfaces allow to implement an interface to any database: `OmnI18n.DB` (who basically just has a `list`) and `OmnI18n.InteractiveDB` who has some modification access + +Two are provided: a `MemDB` who is basically an "in-memory database" and its descendant, a `FileDB` who allows: +- reading from a file +- maintaining the files when changes are brought + +The `FileDB` uses a human-accessible (using [hjson](https://www.npmjs.com/package/hjson) for custom types) and based on `\t` indentation file format only proper for this usage. + +Having the translators managing translations in the UI while the devs have to access the file to add/remove keys, change their zone, ... and all this to go through git commits (so, to have local changes that will be integrated in the project after push/merge) can be done with `FileDB` - for this, just interface a `PUT` to a call on `InteractiveServer::modify` (while that server has a `FileDB` as a source) then the new file will be saved soon with the modified values. + ## Concepts ### Keys @@ -70,6 +82,10 @@ So, downloading `en-US` will download `''` overwritten with `en` then overwritte Common things are formats for example: `format.price: '{number|$2|style: currency, currency: $1}'` for prices allowing `T.format.price(currency, amount)` +#### Fallbacks + +`I18nClient` is constructed with an array of locales. These are the locales "most preferred first". One can easily use the user's settings (often the interface propose "fallbacks") and add hard-coded the language(s) used by the developers. + ### Zones Zones are "software zones". Each user don't need the whole dictionary. Some texts for example are only used in administration pages and should not be downloaded by everyone. @@ -86,15 +102,9 @@ In case of PoC, only the root zone can be used. > :warning: Zones are not different name spaces for text keys, each key is unique and has an associated zone -### `internals` - -Cf. documentation in code. `omni18n` uses the standard JS Intl object. This object is able with a locale to determine some rules. For instance, english has 4 ways to make ordinals (1st, 2nd, 3rd, 4th) while french has 2 (this is already implemented in every browser and node) - -These "internals" are used with specific translation features (like to use `{ordinal|$1} try...`) and should be the same for all websites. - ## Interpolation -A given value like `T.fld.name` will have a javascript value that can be converted in a string _and_ be called. +A given value like `T.fld.name` will have a javascript value that can be converted to a string _and_ be called. The function call will return a pure string and can take arguments. @@ -111,18 +121,18 @@ If the content does not begin with the `=` sign, the content is a list separated - A string - An flat named list in the shape `key1: value1, key2: value2` where only `,` and `:` are used for the syntax. -The `:` character triggers the list parsing. In order to used a ":" in a string, it has to be doubled "::" +> The `:` character triggers the list parsing. In order to used a ":" in a string, it has to be doubled "::" The parameters (given in the code) can be accessed as such: First, the last parameter is the one used for naming. If a named parameter is accessed, the last (or only) parameter should be an object with named properties - `$0` is the key, `$1` the first argument, `$2`... - `$arg` access the argument named `arg` -- `$` access the last argument +- `$` access the last argument (the names object) To add a default, `$arg[default value]` can be used, as well as `$[name: John]` -To use the "$" character, it just has to be doubled: "$$" +To use the `$` character, it just has to be doubled: `$$` The first element will determine how the whole `{...}` will be interpolated @@ -132,7 +142,7 @@ If the first element is a named list, the second one will be the case to take fr example: `{question: ?, exclamation: ! | $1}` -> :information_source: The case `default` get the remaining cases, and if not specified, an error is raised if an inexistent case is given +> :information_source: The case `default` get the remaining cases and, if not specified, an error is raised if an inexistent case is given ### Sub translation @@ -189,8 +199,8 @@ client.interpolate({key: '*', zones: [], client}, '{date|$0|year}', new Date('20 client.interpolate({key: '*', zones: [], client}, '{date|$0|month: numeric}', new Date('2021-11-01T12:34:56.789Z')); // 11 ``` -Also, each locate has a property `timeZone`. If set, it will be the default `timeZone` used in the options. -Its format is the one of `Date.toLocaleString()` +Also, each client has a property `timeZone`. If set, it will be the default `timeZone` used in the options. +Its format is the one taken by `Date.toLocaleString()` #### Other hard-coded @@ -252,4 +262,5 @@ The function might do as much logging as they wish, the returned string will be ## TODOs -- fallback system on missing translations to another language? \ No newline at end of file +- testing the error system +- detailed documentation on each part \ No newline at end of file diff --git a/src/client/client.ts b/src/client/client.ts index 5c65f4a..bdc198e 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -22,13 +22,13 @@ export default class I18nClient implements OmnI18nClient { public timeZone?: string constructor( - public locale: OmnI18n.Locale, + public locales: OmnI18n.Locale[], // On the server side, this is `server.condensed`. From the client-side this is an http request of some sort public condense: OmnI18n.Condense, public onModification?: OmnI18n.OnModification ) { - this.ordinalRules = new Intl.PluralRules(locale, { type: 'ordinal' }) - this.cardinalRules = new Intl.PluralRules(locale, { type: 'cardinal' }) + this.ordinalRules = new Intl.PluralRules(locales[0], { type: 'ordinal' }) + this.cardinalRules = new Intl.PluralRules(locales[0], { type: 'cardinal' }) } get loading() { @@ -43,7 +43,7 @@ export default class I18nClient implements OmnI18nClient { * @returns The translator */ public enter(...zones: string[]) { - if (!zones.length) zones.push('') + zones.push('') const knownZones = this.loadedZones.union(this.toLoadZones), toAdd = zones.filter((zone) => !knownZones.has(zone)) if (toAdd.length) { @@ -70,12 +70,12 @@ export default class I18nClient implements OmnI18nClient { private async download(zones: string[]) { const toLoad = zones.filter((zone) => !this.loadedZones.has(zone)) - if (toLoad.length) this.received(toLoad, await this.condense(this.locale, toLoad)) + if (toLoad.length) this.received(toLoad, await this.condense(this.locales, toLoad)) } - async setLocale(locale: OmnI18n.Locale) { - if (this.locale === locale) return - this.locale = locale + async setLocale(locales: OmnI18n.Locale[]) { + if (this.locales.every((locale, i) => locale == locales[1])) return + this.locales = locales const toLoad = Array.from(this.loadedZones) this.loadedZones = new Set() this.dictionary = {} diff --git a/src/client/index.ts b/src/client/index.ts index e660c7b..e724ee3 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,4 +1,4 @@ export { default as I18nClient } from './client' -export { ClientDictionary, TContext, TranslationError, Translator } from './types' +export { type ClientDictionary, type TContext, TranslationError, type Translator } from './types' export { translator, reports } from './helpers' export { formats, globals, processors } from './interpolation' diff --git a/src/client/interpolation.ts b/src/client/interpolation.ts index c12e23d..827fc78 100644 --- a/src/client/interpolation.ts +++ b/src/client/interpolation.ts @@ -80,7 +80,7 @@ export const processors: Record string> = { currency: globals.currency, ...options } - return num.toLocaleString(client.locale, options) + return num.toLocaleString(client.locales, options) }, date(this: TContext, str: string, options?: any) { const nbr = parseInt(str), @@ -96,7 +96,7 @@ export const processors: Record string> = { timeZone: client.timeZone, ...options } - return date.toLocaleString(client.locale, options) + return date.toLocaleString(client.locales, options) }, relative(this: TContext, str: string, options?: any) { const content = /(-?\d+)\s*(\w+)/.exec(str), @@ -114,32 +114,32 @@ export const processors: Record string> = { return reports.error(this, 'Invalid date options', { options }) options = formats.date[options] } - return new Intl.RelativeTimeFormat(client.locale, options).format( + return new Intl.RelativeTimeFormat(client.locales, options).format( nbr, unit ) }, region(this: TContext, str: string) { return ( - new Intl.DisplayNames([this.client.locale], { type: 'region' }).of(str) || + new Intl.DisplayNames(this.client.locales[0], { type: 'region' }).of(str) || reports.error(this, 'Invalid region', { str }) ) }, language(this: TContext, str: string) { return ( - new Intl.DisplayNames([this.client.locale], { type: 'language' }).of(str) || + new Intl.DisplayNames(this.client.locales[0], { type: 'language' }).of(str) || reports.error(this, 'Invalid language', { str }) ) }, script(this: TContext, str: string) { return ( - new Intl.DisplayNames([this.client.locale], { type: 'script' }).of(str) || + new Intl.DisplayNames(this.client.locales[0], { type: 'script' }).of(str) || reports.error(this, 'Invalid script', { str }) ) }, currency(this: TContext, str: string) { return ( - new Intl.DisplayNames([this.client.locale], { type: 'currency' }).of(str) || + new Intl.DisplayNames(this.client.locales[0], { type: 'currency' }).of(str) || reports.error(this, 'Invalid currency', { str }) ) } diff --git a/src/client/types.ts b/src/client/types.ts index 40a1923..df58b9a 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -17,7 +17,7 @@ export interface OmnI18nClient { internals: Internals readonly ordinalRules: Intl.PluralRules readonly cardinalRules: Intl.PluralRules - locale: OmnI18n.Locale + locales: OmnI18n.Locale[] timeZone?: string interpolate(context: TContext, text: string, args: any[]): string readonly loading: boolean diff --git a/src/db/README.md b/src/db/README.md index 4ed34ce..e08c8d7 100644 --- a/src/db/README.md +++ b/src/db/README.md @@ -2,12 +2,12 @@ These are the implementations of `OmnI18n.InteractiveDB` -## JSonDB +## MemDB -In-memory "DB", initialized with a structured `JsonDictionary` dictionary +In-memory "DB", initialized with a structured `MemDictionary` dictionary ## FileDB -A `JsonDB` that uses a file as a source and persists its change to the file system. +A `MemDB` that uses a file as a source and persists its change to the file system. Note, a human-readable format is used using mostly indent (tabs) to group keys and texts \ No newline at end of file diff --git a/src/db/fileDb.ts b/src/db/fileDb.ts index 643b797..bbd1bf6 100644 --- a/src/db/fileDb.ts +++ b/src/db/fileDb.ts @@ -1,4 +1,4 @@ -import JsonDB, { JsonDictionary, JsonDictionaryEntry } from './jsonDb' +import MemDB, { MemDictionary, MemDictionaryEntry } from './memDb' import { readFile, writeFile, stat } from 'node:fs/promises' import { parse, stringify } from 'hjson' import Defer from '../defer' @@ -11,33 +11,39 @@ function rexCount(str: string, position: number, rex: RegExp = /\u0000/g) { return count } -export default class FileDB extends JsonDB< +export default class FileDB extends MemDB< KeyInfos, TextInfos > { - private save: Defer + private saving: Defer public readonly loaded: Promise constructor( private path: string, - saveDelay = 1e4 // 10 seconds + saveDelay = 1e3 // 1 second ) { super() this.loaded = this.reload() - this.save = new Defer( + this.saving = new Defer( async () => await writeFile(this.path, FileDB.serialize(this.dictionary), 'utf16le'), saveDelay ) } async reload() { - // In case of too much time, write the "modified" call + // In case of too much time, write a "modified" call const fStat = await stat(this.path) if (fStat.isFile() && fStat.size > 0) { const data = await readFile(this.path, 'utf16le') - this.dictionary = JSON.parse(data) + this.dictionary = FileDB.deserialize(data) } } + async save() { + return this.saving.resolve() + } + + //#region Forwards + async list(locales: OmnI18n.Locale[], zone: OmnI18n.Zone) { await this.loaded return super.list(locales, zone) @@ -52,28 +58,29 @@ export default class FileDB extends J } async modify(key: string, locale: OmnI18n.Locale, value: string) { await this.loaded - this.save.defer() + this.saving.defer() return super.modify(key, locale, value) } async key(key: string, zone: string) { await this.loaded - this.save.defer() + this.saving.defer() return super.key(key, zone) } - async remove(key: string) { - await this.loaded - this.save.defer() - return super.remove(key) - } async get(key: string) { await this.loaded return super.get(key) } + async reKey(key: string, newKey?: string) { + await this.loaded + this.saving.defer() + return super.reKey(key, newKey) + } + //#endregion //#region serialization static serialize( - dictionary: JsonDictionary + dictionary: MemDictionary ) { function optioned(obj: any, preTabs = 0) { const stringified = stringify(obj, { @@ -81,11 +88,10 @@ export default class FileDB extends J multiline: 'std', space: '\t' }) - return stringified.length < 80 + /*stringified.length < 80 ? stringified.replace(/[\n\t]/g, '') - : preTabs - ? stringified.replace(/\n/g, '\n' + '\t'.repeat(preTabs)) - : stringified + :*/ + return preTabs ? stringified.replace(/\n/g, '\n' + '\t'.repeat(preTabs)) : stringified } let rv = '' for (const [key, value] of Object.entries(dictionary)) { @@ -118,8 +124,9 @@ export default class FileDB extends J } static deserialize(data: string) { - const dictionary: JsonDictionary = {} - data = data.replace(/\n/gm, '\u0000') + if (!data.endsWith('\n')) data += '\n' + const dictionary: MemDictionary = {} + data = data.replace(/\n/g, '\u0000') // Only way to make regexp treat '\n' as a regular character const rex = { key: /([^\t\{:]+)(\{.*?\})?:([^\u0000]*)\u0000/g, locale: /\t([^\t\{:]*)(\{.*?\})?(?::((?:[^\u0000]|\u0000\t\t)*))?\u0000/g @@ -136,7 +143,7 @@ export default class FileDB extends J let keyInfos: any, textInfos: Record = {} if (keyFetch[2]) keyInfos = parse(keyFetch[2].replace(/\u0000/g, '\n')) - const entry: JsonDictionaryEntry = { + const entry: MemDictionaryEntry = { '.zone': zone, ...(keyInfos && { '.keyInfos': keyInfos }) } diff --git a/src/db/index.ts b/src/db/index.ts index 04293f8..a31bd28 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,2 +1,2 @@ -export { default as JsonDB, JsonDictionary, JsonDictionaryEntry } from './jsonDb' +export { default as MemDB, type MemDictionary, type MemDictionaryEntry } from './memDb' export { default as FileDB } from './fileDb' diff --git a/src/db/jsonDb.ts b/src/db/memDb.ts similarity index 80% rename from src/db/jsonDb.ts rename to src/db/memDb.ts index 42b4aae..a6c5b01 100644 --- a/src/db/jsonDb.ts +++ b/src/db/memDb.ts @@ -4,29 +4,25 @@ interface SystemEntry { '.textInfos'?: Record } -export type JsonDictionaryEntry = { +export type MemDictionaryEntry = { [k: Exclude>]: string } & SystemEntry -export type JsonDictionary = { - [key: string]: JsonDictionaryEntry +export type MemDictionary = { + [key: string]: MemDictionaryEntry } -export default class JsonDB +export default class MemDB implements OmnI18n.InteractiveDB { - constructor(public dictionary: JsonDictionary = {}) {} + constructor(public dictionary: MemDictionary = {}) {} async list(locales: OmnI18n.Locale[], zone: OmnI18n.Zone) { const result: OmnI18n.RawDictionary = {} Object.entries(this.dictionary).forEach(([key, value]) => { if (zone == value['.zone']) { - let mLocale: OmnI18n.Locale | false = false - for (const locale in value) { - if (locales.includes(locale) && (!mLocale || locale.length > mLocale.length)) - mLocale = locale - } - if (mLocale !== false) result[key] = value[mLocale] + const locale = locales.find((locale) => locale in value) + if (locale !== undefined) result[key] = [locale, value[locale]] } }) return result @@ -83,7 +79,7 @@ export default class JsonDB ez = entry['.zone'] if (!/^[\w\-\+\*\.]*$/g.test(key)) throw new Error(`Bad key-name: ${key} (only letters, digits, "_+-*." allowed)`) - this.dictionary[key] = >{ + this.dictionary[key] = >{ ...entry, ...((entry['.keyInfos'] || keyInfos) && { '.keyInfos': { @@ -99,11 +95,12 @@ export default class JsonDB return zone !== ez } - async remove(key: string) { + async reKey(key: string, newKey?: string) { const rv = { locales: Object.keys(this.dictionary[key] || {}), zone: this.dictionary[key]['.zone'] } + if (newKey) this.dictionary[newKey] = this.dictionary[key] delete this.dictionary[key] return rv } diff --git a/src/defer.ts b/src/defer.ts index a3383c7..7ee466a 100644 --- a/src/defer.ts +++ b/src/defer.ts @@ -1,6 +1,7 @@ export default class Defer { private promise: Promise = Promise.resolve() - private reject?: (reason?: any) => void + private rejecter?: (reason?: any) => void + private resolver?: (value?: any) => void timeout: any private internalCB?: () => Promise @@ -13,15 +14,14 @@ export default class Defer { if (cb) this.cb = cb if (this.timeout) clearTimeout(this.timeout) else { - let resolver: (value?: any) => void this.promise = new Promise((resolve, reject) => { - resolver = resolve - this.reject = reject + this.resolver = resolve + this.rejecter = reject }) this.internalCB = async () => { - if (this.cb) await this.cb() this.timeout = undefined - resolver() + if (this.cb) await this.cb() + this.resolver!() } } this.timeout = setTimeout(this.internalCB!, this.delay) @@ -35,7 +35,12 @@ export default class Defer { cancel() { if (!this.timeout) return clearTimeout(this.timeout) - this.reject!() + this.rejecter!() this.timeout = undefined } + + resolve() { + if (this.timeout) this.internalCB?.() + return this.promise + } } diff --git a/src/server/interactive.ts b/src/server/interactive.ts index 349ba5b..88a0db1 100644 --- a/src/server/interactive.ts +++ b/src/server/interactive.ts @@ -20,12 +20,15 @@ export default class InteractiveServer< constructor( protected db: OmnI18n.InteractiveDB, - private modified: (entries: Record) => Promise + private modified = (entries: Record) => Promise.resolve() ) { super(db) subscriptions.set(this, { locale: '', zones: [] }) } + workList(locales: OmnI18n.Locale[]): Promise { + return this.db.workList(locales) + } isSpecified(key: string, locales: OmnI18n.Locale[]): Promise { return this.db.isSpecified(key, locales) } @@ -72,13 +75,13 @@ export default class InteractiveServer< * @param zone * @returns */ - condense(locale: string, zones?: string[]): Promise { + condense(locales: OmnI18n.Locale[], zones?: string[]): Promise { const sub = subscriptions.get(this) if (sub) { - sub.locale = locale + sub.locale = locales[0] sub.zones = [...sub.zones, ...(zones || [])] } - return super.condense(locale, zones) + return super.condense(locales, zones) } /** @@ -128,8 +131,11 @@ export default class InteractiveServer< }) ) } - async remove(key: string): Promise { - const { zone, locales } = await this.db.remove(key) - for (const locale of locales) this.modifications.push([key, locale, zone, undefined]) + async reKey(key: string, newKey?: string): Promise { + const { zone, locales } = await this.db.reKey(key, newKey) + for (const locale of locales) { + this.modifications.push([key, locale, zone, undefined]) + if (newKey) this.modifications.push([newKey, locale, zone, undefined]) + } } } diff --git a/src/server/server.ts b/src/server/server.ts index 7725cd8..448ba0f 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -9,7 +9,9 @@ type CDicE = CDic & string export function localeTree(locale: OmnI18n.Locale) { const parts = locale.split('-') - return parts.map((_, i) => parts.slice(0, i + 1).join('-')) + const rv = [] + for (let i = parts.length; i > 0; i--) rv.push(parts.slice(0, i).join('-')) + return rv } /** @@ -20,23 +22,25 @@ export default class I18nServer { - return this.db.list(['', ...localeTree(locale)], zone) + list(locales: OmnI18n.Locale[], zone: string): Promise { + const [primary, ...fallbacks] = locales + return this.db.list([...localeTree(primary), '', ...fallbacks.map(localeTree).flat()], zone) } /** * Used by APIs or page loaders to get the dictionary in a condensed form - * @param locale - * @param zone + * @param locales List of locales in order of preference - later locales are only used if the previous ones had no traduction for a key. + * @param zones List of zones to condense. * @returns */ async condense( - locale: OmnI18n.Locale, + locales: OmnI18n.Locale[], zones: OmnI18n.Zone[] = [''] ): Promise { - const raws = await Promise.all(zones.map((zone) => this.list(locale, zone))), + const raws = await Promise.all(zones.map((zone) => this.list(locales, zone))), results: OmnI18n.CondensedDictionary[] = [] for (const raw of raws) { const result: OmnI18n.CondensedDictionary = {} + let hasValue = false results.push(result) for (const key in raw) { const value = raw[key], @@ -44,15 +48,44 @@ export default class I18nServer{} + if (!(k in current)) current[k] = {} else if (typeof current[k] === 'string') current[k] = { '': current[k] } current = current[k] as CDic } - if (current[lastKey] && typeof current[lastKey] !== 'string') - (current[lastKey])[''] = value - else current[lastKey] = value + if (!hasValue || locales[0].startsWith(value[0])) { + if (current[lastKey] && typeof current[lastKey] !== 'string') + (current[lastKey])[''] = value[1] + else current[lastKey] = value[1] + hasValue = true + } + } + } /* + for (const fallback of fallbacks) { + const raws = await Promise.all(zones.map((zone) => this.list(fallback, zone))) + for (let i = 0; i < raws.length; i++) { + const raw = raws[i], + result = results[i] + for (const key in raw) { + const value = raw[key], + keys = key.split('.'), + lastKey = keys.pop() as string + let current: OmnI18n.CondensedDictionary | null = result + for (const k of keys) { + if (!(k in current)) current![k] = {} + else if ( + typeof current[k] === 'string' || + (current[k])[''] + ) { + current = null + break + } + current = current[k] as CDic + } + if (current && (!current[lastKey] || typeof current[lastKey] === 'string')) + current[lastKey] = value + } } - } + }*/ return results } } diff --git a/src/types.d.ts b/src/types.d.ts index 002a983..a3f002b 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,12 +1,20 @@ declare namespace OmnI18n { + // shortcuts, it's just a string at the end of the day, it helps copilot + type Locale = Intl.UnicodeBCP47LocaleIdentifier + type Zone = string + type CondensedDictionary = { [key: Exclude]: CondensedDictionary | string ''?: string } - type Condense = (locale: Locale, zones: Zone[]) => Promise - type OnModification = (entries?: Locale[]) => void - type RawDictionary = Record + type Condense = (locales: Locale[], zones: Zone[]) => Promise + type OnModification = (keys?: string[]) => void + /** + * Dictionary used between the server and the DB + * key => [locale, text] + */ + type RawDictionary = Record type WorkDictionaryText = { text: string infos: TextInfos @@ -16,35 +24,34 @@ declare namespace OmnI18n { zone: Zone infos: KeyInfos } + /** + * Used for translator-related operations + */ type WorkDictionary = Record - // shortcut - type Locale = Intl.UnicodeBCP47LocaleIdentifier - - type Zone = string - - interface DB { + interface DB { /** * Retrieves all the values for a certain zone * @param locales A list of locales to search for + * @param zone The zone to search in + * @returns A dictionary of key => [locale, text], where locale is the first on the list that has a translation */ list(locales: Locale[], zone: Zone): Promise - - /** - * Retrieves all the locales given for a certain key - * @param key The key to search for - */ - get(key: string): Promise> } - interface InteractiveDB - extends DB { + interface InteractiveDB extends DB { /** * Retrieves all the values for certain locales, in order for translators to work on it * @param locales */ workList(locales: Locale[]): Promise + /** + * Retrieves all the locales given for a certain key + * @param key The key to search for + */ + get(key: string): Promise> + /** * Checks if a key is specified in a certain locale * @param key The key to search for @@ -76,12 +83,11 @@ declare namespace OmnI18n { key(key: string, zone: string, keyInfos?: Partial): Promise /** - * Removes a key (and all its translations) - * @param key The key to remove + * Renames a key - or removes it (and its translation) if newKey is undefined + * @param key + * @param newKey * @returns The zone where the key was stored and the locales where it was translated */ - remove(key: string): Promise<{ zone: string; locales: Locale[] }> - - //TODO reKey(oldKey, newKey) + reKey(key: string, newKey?: string): Promise<{ zone: string; locales: Locale[] }> } } diff --git a/test/db.ts b/test/db.ts index c08e81e..fa01f4a 100644 --- a/test/db.ts +++ b/test/db.ts @@ -1,4 +1,4 @@ -import JsonDB, { JsonDictionary } from '../src/db/jsonDb' +import MemDB, { MemDictionary } from '../src/db/memDb' // As this is for test purpose, actually wait even for direct-memory operations function waiting(func: () => Promise) { @@ -16,9 +16,6 @@ export class WaitingDB implements OmnI18n.InteractiveDB { key(key: string, zone: string) { return waiting(() => this.db.key(key, zone)) } - remove(key: string) { - return waiting(() => this.db.remove(key)) - } list(locales: OmnI18n.Locale[], zone: OmnI18n.Zone) { return waiting(() => this.db.list(locales, zone)) } @@ -28,4 +25,7 @@ export class WaitingDB implements OmnI18n.InteractiveDB { workList(locales: string[]): Promise { return waiting(() => this.db.workList(locales)) } + reKey(key: string, newKey?: string): Promise<{ zone: string; locales: OmnI18n.Locale[] }> { + return waiting(() => this.db.reKey(key, newKey)) + } } diff --git a/test/dynamic.test.ts b/test/dynamic.test.ts index 3d5ae32..ceb8856 100644 --- a/test/dynamic.test.ts +++ b/test/dynamic.test.ts @@ -1,5 +1,5 @@ import { WaitingDB } from './db' -import { Translator, I18nClient, InteractiveServer, JsonDB } from '../src/index' +import { Translator, I18nClient, InteractiveServer, MemDB } from '../src/index' describe('Dynamic functionality', () => { let server: InteractiveServer, @@ -10,7 +10,7 @@ describe('Dynamic functionality', () => { beforeAll(async () => { server = new InteractiveServer( new WaitingDB( - new JsonDB({ + new MemDB({ 'fld.name': { en: 'Name', '.zone': '' }, 'cmd.customize': { en: 'Customize', 'en-UK': 'Customise', '.zone': '' }, 'cmd.save': { en: 'Save', '.zone': 'adm' }, @@ -25,7 +25,7 @@ describe('Dynamic functionality', () => { } } ) - client = new I18nClient('en-UK', server.condense) + client = new I18nClient(['en-UK'], server.condense) T = client.enter() await client.loaded }) @@ -81,7 +81,7 @@ describe('Dynamic functionality', () => { expect(modifications).toEqual([{ 'cmd.delete': ['Remove', ''] }]) modifications = [] expect(T.cmd.delete()).toBe('Remove') - await server.remove('cmd.delete') + await server.reKey('cmd.delete') await server.save() expect(modifications).toEqual([{ 'cmd.delete': undefined }]) modifications = [] diff --git a/test/specifics.test.ts b/test/specifics.test.ts new file mode 100644 index 0000000..adead1e --- /dev/null +++ b/test/specifics.test.ts @@ -0,0 +1,87 @@ +import { + FileDB, + I18nClient, + I18nServer, + InteractiveServer, + MemDB, + MemDictionary +} from '../src/index' +import { WaitingDB } from './db' +import { readFile, writeFile, unlink, cp } from 'node:fs/promises' + +describe('fallback', () => { + test('errors', async () => { + // TODO test errors + }) + test('fallbacks', async () => { + const server = new I18nServer( + new WaitingDB( + new MemDB({ + 'fld.name': { en: 'Name', '.zone': '' }, + 'fld.bday': { en: 'Birthday', fr: 'Anniversaire', '.zone': '' }, + 'fld.bday.short': { en: 'Bday', '.zone': '' } + }) + ) + ), + client = new I18nClient(['fr', 'en'], server.condense), + T = client.enter() + await client.loaded + expect('' + T.fld.name).toBe('Name') + expect('' + T.fld.bday.short).toBe('Anniversaire') + }) + test('serialize', () => { + const content: MemDictionary = { + 'serializations.nl1': { + '': 'Line 1\nLine2', + '.zone': '' + }, + 'fld.name': { en: 'Name', fr: 'Nom', '.zone': 'sls' }, + 'serializations.nl2': { + '': 'Line 1\nLine2', + '.zone': 'nls' + } + } + Object.assign(content['fld.name'], { + ['.keyInfos']: { a: 1 }, + ['.textInfos']: { en: { a: '"\'`' }, hu: { a: 3 } } + }) + const serialized = FileDB.serialize(content) + expect(FileDB.deserialize(serialized)).toEqual(content) + }) + test('file DB', async () => { + await writeFile( + './db.test', + `fld.name{note: "the name of the person"}: + en:Name + fr{obvious: true}:Nom +test.multiline: + :Line 1 + Line 2 +`, + 'utf16le' + ) + const db = new FileDB('./db.test') + await db.loaded + expect(db.dictionary['fld.name']?.en).toBe('Name') + expect(db.dictionary['fld.name']['.keyInfos']?.note).toBe('the name of the person') + expect(db.dictionary['fld.name']['.textInfos']?.fr.obvious).toBe(true) + expect(db.dictionary['test.multiline']?.['']).toBe('Line 1\nLine 2') + const server = new InteractiveServer(db) + await server.modify('fld.name', 'hu', 'Név') + await db.save() + const content = await readFile('./db.test', 'utf16le') + expect(content).toBe(`fld.name{ + note: the name of the person +}: + en:Name + fr{ + obvious: true + }:Nom + hu:Név +test.multiline: + :Line 1 + Line 2 +`) + await unlink('./db.test') + }) +}) diff --git a/test/static.test.ts b/test/static.test.ts index 3bb3653..ad39eae 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -1,8 +1,6 @@ -import { I18nClient, Translator, I18nServer, JsonDB, JsonDictionary, FileDB } from '../src/index' +import { I18nClient, Translator, I18nServer, MemDB } from '../src/index' import { WaitingDB } from './db' -// TODO: test errors - // This is for test purpose: in general usage, only one locale/T is used let server: I18nServer, T: Record, @@ -12,7 +10,7 @@ let server: I18nServer, beforeAll(async () => { server = new I18nServer( new WaitingDB( - new JsonDB({ + new MemDB({ 'fld.name': { en: 'Name', fr: 'Nom', '.zone': '' }, 'fld.bdate': { en: 'Birthday', fr: 'Date de naissance', '.zone': '' }, 'fld.bdate.short': { en: 'B-dy', '.zone': '' }, @@ -71,11 +69,11 @@ beforeAll(async () => { ) ) - function condense(locale: OmnI18n.Locale, zones: string[] = ['']) { - loads.push({ locale, zones }) - return server.condense(locale, zones) + function condense(locales: OmnI18n.Locale[], zones: string[] = ['']) { + loads.push({ locales, zones }) + return server.condense(locales, zones) } - clients = { en: new I18nClient('en-US', condense), be: new I18nClient('fr-BE', condense) } + clients = { en: new I18nClient(['en-US'], condense), be: new I18nClient(['fr-BE'], condense) } clients.en.enter('adm') clients.be.timeZone = 'Europe/Brussels' T = Object.fromEntries(Object.entries(clients).map(([key, value]) => [key, value.enter()])) @@ -208,42 +206,16 @@ describe('parameters', () => { loads = [] clients.be.enter('adm') await clients.be.loaded - expect(loads).toEqual([{ locale: 'fr-BE', zones: ['adm'] }]) + expect(loads).toEqual([{ locales: ['fr-BE'], zones: ['adm'] }]) expect(T.be.cmd.ban()).toBe("Bannir l'utilisateur") }) test('change locale', async () => { - const client = new I18nClient('en-US', server.condense), + const client = new I18nClient(['en-US'], server.condense), T: Translator = client.enter() await client.loaded expect(T.msg.greet()).toBe('Hello here') - await client.setLocale('fr') + await client.setLocale(['fr']) expect(T.msg.greet()).toBe('Salut tout le monde') }) }) - -describe('errors', () => { - // TODO: test errors -}) - -describe('serialization', () => { - test('serialize', () => { - const content: JsonDictionary = { - 'serializations.nl1': { - '': 'Line 1\nLine2', - '.zone': '' - }, - 'fld.name': { en: 'Name', fr: 'Nom', '.zone': 'sls' }, - 'serializations.nl2': { - '': 'Line 1\nLine2', - '.zone': 'nls' - } - } - Object.assign(content['fld.name'], { - ['.keyInfos']: { a: 1 }, - ['.textInfos']: { en: { a: '"\'`' }, hu: { a: 3 } } - }) - const serialized = FileDB.serialize(content) - expect(FileDB.deserialize(serialized)).toEqual(content) - }) -})