UNPKG

ez-localize

Version:

Super-simple localization of strings in a Node/Browserify application

328 lines (270 loc) 8.91 kB
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 }