The main structure is key -> translations
. In SQL, it would be translated as 2 tables:
- One for the key, the text-key - like "msg.greet" - being the primary key
- One for the translations, where the primary key is the couple (text-key, locale)
In mongo or json-oriented DB, a key object could directly give a list of translations locale -> text
The database interface is the one provided to the server. The first one is fairly simple:
export type RawDictionary = Record<TextKey, [Locale, Translation]>
interface DB {
list(locales: Locale[], zone: Zone): Promise<RawDictionary>
}
That list
function is a glorified SELECT
who gives all keys given in a zone and, for them, the first locale from the given list that has a translation - and the translation of course
Note: In order not to import the whole
omni18n
library, the entry-pointomni18n/db-dev
exposes all the types and the few helpers described below.
The DB role is purely to deal with a database. The server
will often mimic functions and their signature (modify
, reKey
, ...) and while the server
role is to propagate information to both the client and the DB, a DB's role is purely the one of an adapter toward a database.
Locale
, Zone
, TextKey
, Translation
... are basically strings.
Translation occur with simple "select/upsert" operations. There is no key management here, no structure management, just content edition
Here, we get already in the realm where we can specify KeyInfos
and TextInfos
.
The former is given by developers, in english or some common language if comments are needed, it might contain the type (text/html/md/...) for the translation interface, &c. - and appears in the keys
database
The latter more often used/edited by the translators and appearing in the translations
database. (comment, "Keep the default value"="Do not translate" tag, &c.)
If a database implementation is meant to be generic, it should store the ...Infos
as json I guess or something, but an application can specify both these generic arguments and the database adapter to deal with it.
...Infos extends {} = {}
type WorkDictionaryText<TextInfos> = {
text?: Translation
infos?: TextInfos
}
type WorkDictionaryEntry<KeyInfos, TextInfos> = {
texts: { [locale: Locale]: WorkDictionaryText<TextInfos> }
zone: Zone
infos: KeyInfos
}
type WorkDictionary = Record<string, WorkDictionaryEntry>
workList(locales: Locale[]): Promise<WorkDictionary>
Given a list of locales, find all their translations
No
zone
fuss: this is read-only at this level, and it's not "the first translation", it's all of them.
This function is indeed used to populate translator's list for working on it ... working list.
Sets the text/TextInfo
for a given text-key/locale pair.
Returns the zone of the text-key if modified, false
if the text-key was not found
Write in the texts table, read in the keys table
modify(
key: TextKey,
locale: Locale,
text: Translation,
textInfos?: Partial<TextInfos>
): Promise<Zone | false>
Provides edition for the developer. (note: the querying goes through workList
of TranslatableDB
)
key(key: string, zone: string, keyInfos?: Partial<KeyInfos>): Promise<boolean>
reKey(key: string, newKey?: string): Promise<{ zone: Zone; locales: Locale[] }>
key
just upsert a key and its relative information.
reKey
renames a key - into oblivion if no newKey
(in the later case, removes also the translations)
The last one has some little query functions used in interactive mode (ie. when the text changes should be populated to all clients when done)
get(key: string): Promise<Record<Locale, Translation>>
getZone(key: TextKey, locales?: Locale[]): Promise<Zone>
The first one retrieves the list of translations for a key, the second the key's zone IF some of the locales have a translation
MemDB
is an in-memory database (a pure JS object) who is build with its internal dictionary (MemDBDictionary
)
This is the solution to use to cache the whole translation database in memory once for all.
These can be loaded either from a list of database rows or JSON files, the database can therefore be loaded with
const db = new MemDB(loadDBfromXXX(...))
// And for reloading
db.dictionary = loadDBfromXXX(...)
When your database is a JSON file per language, just loading them and loading into one memory dictionary.
/**
* Load an in-memory structure out of raw DB output
* @param translations The list of translation files (recorded per locale)
* @returns
*/
export function loadDBFromTranslations(
translations: Record<Locale, Record<TextKey, Translation>>
): MemDB
Cache the whole language database in a memory dictionary. This is the most straightforward way to go from a database: give it a list or database row (or join) key-locale-text, and it's done.
/**
* Load an in-memory structure out of raw DB output
* @param raw Raw rows from a DB
* @returns
*/
export function loadDBFromList(
raw: Iterable<{text: TextKey, locale: Locale, text: Translation, zone?: Zone}>
): MemDB
FileDB is basically a MemDB with a file I/O ability. It is constructed with a file name and a delay
, specifying the defer time between modifications and file writing (if a modification intervenes before file writing, the writing is deferred again to group it)
This allows:
- All the translations to simply be gathered under a file under source control (backup-able)
- The development activities (adding/removing/removing/rezoning a key) to be made and applied on commit/merge, and the "translation" (text-change) activities to still be available through the UI in real time
⚠️ The file should be in UTF-16 LE in strictLF
mode
A FileDB.analyze
function is exposed who takes the string to analyze and 2/3 callbacks
onKey
called when a new key is discoveredonText
called when a translation is discoveredendKey?
called when the key is finished
For each key, the callback calls will be onKey - onText* - endKey
for each key
The serialization file-format is specific for regexp-ability and human interactions; grouping is done by indentation (made with tabulations - \t
).
KeyInfos
and TextInfos
are stored in human-accessible js-like format
A line beginning with no tabs is a key specification
[text-key]:[zone]
[text-key][{ SomeKeyInfos: 'jju format' }]:[zone]
Note: the zone can and will often be
""
A line beginning with one tab is a locale specification for the key "en cours"
[locale]:Some fancy translation
[locale][{ SomeTextInfos: 'value' }]:Some fancy translation
A line beginning with two tabs is the continuation of the translation.
[locale]:Line1
Line2
Will specify locale: "Line1\nLine2"
A line beginning with three tabs is the continuation of a translation containing a tab ... &c.
As the list
query can really be tricky, some ways are provided so that simpler queries can be written (but will consume more API server time and transfer between API server and DB server).
Will implement:
/**
* Retrieves all the values for a certain zone and a certain locales
* @param locale The locale to search for
* @param zone The zone to search in
* @param exclusion A list of keys to exclude
* @returns A dictionary of key => text
*/
listLocale(
locale: Locale,
zone: Zone,
exclusion: TextKey[]
): Promise<[TextKey, Translation][]>
The function will be called for each needed locales with the list of keys not to retrieve
Will implement:
/**
* Retrieves all the values for a certain zone and a certain locales
* @param locales A list of locales to search for
* @param zone The zone to search in
* @returns A dictionary of key => text
*/
exhaustiveList(locales: Locale[], zone: Zone): Promise<[Locale, TextKey, Translation][]>
The function will be called once and should retrieve a list of [Locale, TextKey, Translation]
sorted by the position of the locale in the list.
If ordering is still too complex to make on the DB-side, the class provides for convenience:
/**
* Call this function if this was not done in the query: if locales are [l1, l2, ...], make sure that all the l1 appear first, then the l2, ...
* @param locales The given list of locale priority
* @param exhaustive The exhaustive list of unsorted [Locale, TextKey, Translation]
*/
sortByLocales(
locales: Locale[],
exhaustive: [Locale, TextKey, Translation][]
): [Locale, TextKey, Translation][]