ez-localize
Version:
Super-simple localization of strings in a Node/Browserify application
328 lines (270 loc) • 8.91 kB
text/typescript
import _ from "lodash"
import xlsx from "xlsx"
import { LocalizerData } from "."
export interface LocalizedString {
_base: string
[language: string]: string // Localizations
}
export interface Locale {
/** ISO code for locale (e.g. "en") */
code: string
/** Local name for locale (e.g. Espanol) */
name: string
}
/** Extracts localized strings from a plain object */
export function extractLocalizedStrings(obj: any): LocalizedString[] {
if (obj == null) {
return []
}
// Return self if string
if (obj._base != null) {
return [obj]
}
let strs: any = []
// If array, concat each
if (_.isArray(obj)) {
for (let item of obj) {
strs = strs.concat(extractLocalizedStrings(item))
}
} else if (_.isObject(obj)) {
for (let key in obj) {
const value = obj[key]
strs = strs.concat(extractLocalizedStrings(value))
}
}
return strs
}
/** Keep unique base language string combinations */
export function dedupLocalizedStrings(strs: LocalizedString[]): LocalizedString[] {
const out = []
const keys: Record<string, boolean> = {}
for (let str of strs) {
const key = str._base + ":" + str[str._base]
if (keys[key]) {
continue
}
keys[key] = true
out.push(str)
}
return out
}
/** Change the base locale for a set of localizations.
* Works by making whatever the user sees as the toLocale base
*/
export function changeBaseLocale(strs: LocalizedString[], fromLocale: string, toLocale: string): void {
for (let str of strs) {
// Get displayed
var displayed
if (str[fromLocale]) {
displayed = str[fromLocale]
delete str[fromLocale]
} else if (str[str._base]) {
displayed = str[str._base]
delete str[str._base]
}
if (displayed) {
str[toLocale] = displayed
str._base = toLocale
}
}
}
/** Update a set of strings based on newly localized ones. Mutates the original strings. Optionally specify a locale to update. */
export function updateLocalizedStrings(strs: LocalizedString[], updates: LocalizedString[], locale?: string): void {
// Regularize CR/LF and trim
const regularize = (str: any) => {
if (!str) {
return str
}
return str.replace(/\r/g, "").trim()
}
// Map updates by key
const updateMap: Record<string, LocalizedString> = {}
for (let update of updates) {
updateMap[update._base + ":" + regularize(update[update._base])] = update
}
// Apply to each str
for (let str of strs) {
const match = updateMap[str._base + ":" + regularize(str[str._base])]
if (match != null) {
for (let key in match) {
// If locale is specified, only update that locale
if (locale && key !== locale) {
continue
}
const value = match[key]
// Ignore _base and _unused (_unused is legacy)
if (key !== "_base" && key !== str._base && key !== "_unused") {
// Remove blank values
if (value) {
str[key] = regularize(value)
} else {
delete str[key]
}
}
}
}
}
}
/** Exports localized strings for specified locales to XLSX file. Returns base64 */
export function exportXlsx(locales: Locale[], strs: LocalizedString[]): string {
let locale
const wb: any = { SheetNames: [], Sheets: {} }
const range = { s: { c: 10000000, r: 10000000 }, e: { c: 0, r: 0 } }
const ws: any = {}
function addCell(row: any, column: any, value: any) {
// Update ranges
if (range.s.r > row) {
range.s.r = row
}
if (range.s.c > column) {
range.s.c = column
}
if (range.e.r < row) {
range.e.r = row
}
if (range.e.c < column) {
range.e.c = column
}
// Create cell
const cell = { v: value, t: "s" }
const cell_ref = xlsx.utils.encode_cell({ c: column, r: row })
return (ws[cell_ref] = cell)
}
let localeCount = 0
addCell(0, localeCount++, "Original Language")
if (!_.findWhere(locales, { code: "en" })) {
locales = locales.concat([{ code: "en", name: "English" }])
}
// Add locale columns
for (locale of locales) {
addCell(0, localeCount++, locale.name)
}
// Add rows
let rows = 0
for (let str of strs) {
const base = _.findWhere(locales, { code: str._base })
// Skip if unknown
if (!base) {
continue
}
let columns = 0
rows++
addCell(rows, columns++, base.name)
for (locale of locales) {
addCell(rows, columns++, str[locale.code] || "")
}
}
// Encode range
if (range.s.c < 10000000) {
ws["!ref"] = xlsx.utils.encode_range(range)
}
// Add worksheet to workbook */
wb.SheetNames.push("Translation")
wb.Sheets["Translation"] = ws
const wbout = xlsx.write(wb, { bookType: "xlsx", bookSST: true, type: "base64" })
return wbout
}
/** Import from base64 excel */
export function importXlsx(locales: Locale[], xlsxFile: string): LocalizedString[] {
const wb = xlsx.read(xlsxFile, { type: "base64" })
const ws = wb.Sheets[wb.SheetNames[0]]!
// If English is not a locale, append it, as built-in form elements
// are specified in English
if (!_.findWhere(locales, { code: "en" })) {
locales = locales.concat([{ code: "en", name: "English" }])
}
const strs = []
// Get the range of cells
const lastCell = ws["!ref"]!.split(":")[1]
const totalColumns = xlsx.utils.decode_cell(lastCell).c + 1
const totalRows = xlsx.utils.decode_cell(lastCell).r + 1
// For each rows
for (let i = 1, end = totalRows, asc = 1 <= end; asc ? i < end : i > end; asc ? i++ : i--) {
// Get base locale
const base = _.findWhere(locales, { name: ws[xlsx.utils.encode_cell({ c: 0, r: i })]?.v })
// Skip if unknown
if (!base) {
continue
}
const str: LocalizedString = { _base: base.code }
for (let col = 1, end1 = totalColumns, asc1 = 1 <= end1; asc1 ? col < end1 : col > end1; asc1 ? col++ : col--) {
const cell = ws[xlsx.utils.encode_cell({ c: col, r: i })]
if (!cell) {
continue
}
let val = cell.v
// If value and not NaN, store in string
if (val != null && val !== "" && val === val) {
// Convert to string
val = String(val)
} else {
// Any invalid value is considered empty
val = ""
}
// Get locale of cell
const locale = _.findWhere(locales, { name: ws[xlsx.utils.encode_cell({ c: col, r: 0 })]?.v })
if (locale) {
str[locale.code] = val
}
}
// Ignore if base language blank
if (str[str._base]) {
strs.push(str)
}
}
return strs
}
/** Remove unused strings from a LocalizerData object */
export function removeUnusedStrings(data: LocalizerData): LocalizerData {
const unused: Record<string, boolean> = {}
for (const str of data.unused || []) {
unused[str] = true
}
return {
...data,
strings: data.strings.filter(str => !unused[str[str._base]]),
unused: []
}
}
/** Merge multiple LocalizerData objects. Merges locales and strings, then determines unused strings by
* union of all unused strings from inputs then removing any strings that are actually used.
* Prefers translations from later inputs over earlier ones. */
export function mergeLocalizerData(inputs: LocalizerData[]): LocalizerData {
const merged: LocalizerData = { locales: [], strings: [], unused: [] }
// Merge locales
merged.locales = _.uniq([...inputs.map(i => i.locales).flat()], "code")
// Create a map of merged strings by <base locale>:<base string>
const mergedStringsMap: Record<string, LocalizedString> = {}
// Merge strings
for (const input of inputs) {
for (const str of input.strings) {
const key = str._base + ":" + str[str._base]
if (mergedStringsMap[key]) {
mergedStringsMap[key] = mergeLocalizedString(mergedStringsMap[key], str)
} else {
mergedStringsMap[key] = str
}
}
}
merged.strings = Object.values(mergedStringsMap)
// Determine unused strings by union of all unused strings from inputs
// then removing any strings that are actually used
const knownStrings = new Set(merged.strings.map(s => s[s._base]))
merged.unused = _.uniq([...inputs.map(i => i.unused || []).flat()])
merged.unused = merged.unused.filter(str => !knownStrings.has(str))
return merged
}
/** Merge two localized strings. Assumes they have the same base locale. Prefers values from b over a */
function mergeLocalizedString(a: LocalizedString, b: LocalizedString): LocalizedString {
if (a._base !== b._base) {
throw new Error("Cannot merge strings with different base locales")
}
// Merge, ignoring _base and _unused and blank values
const merged: LocalizedString = { ...a }
for (const key in b) {
if (key !== "_base" && key !== "_unused" && b[key]) {
merged[key] = b[key]
}
}
return merged
}