226 lines
6.0 KiB
TypeScript
226 lines
6.0 KiB
TypeScript
import type { Kysely } from 'kysely'
|
|
import type { KyselyDB } from '../database-types.js'
|
|
import type { TranslationTokens } from '../engines/language.types.gen.js'
|
|
import {
|
|
backendTranslationSnapshot,
|
|
type BackendTranslationNamespace,
|
|
} from './backend-translation-snapshot.js'
|
|
|
|
type LocaleData = Record<string, any>
|
|
type Locales = Record<string, LocaleData>
|
|
|
|
type BackendTranslationRow = {
|
|
locale: string
|
|
namespace: BackendTranslationNamespace
|
|
contentJson: unknown
|
|
}
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
}
|
|
|
|
function parseLocaleData(value: unknown): LocaleData {
|
|
if (typeof value === 'string') {
|
|
return parseLocaleData(JSON.parse(value))
|
|
}
|
|
|
|
if (isPlainObject(value)) {
|
|
return value as LocaleData
|
|
}
|
|
|
|
return {}
|
|
}
|
|
|
|
function syncTranslationValue(existing: unknown, baseline: unknown): unknown {
|
|
if (Array.isArray(baseline)) {
|
|
if (!Array.isArray(existing)) {
|
|
return baseline
|
|
}
|
|
|
|
return baseline.map((item, index) => syncTranslationValue(existing[index], item))
|
|
}
|
|
|
|
if (isPlainObject(baseline)) {
|
|
const existingObject = isPlainObject(existing) ? existing : {}
|
|
return Object.fromEntries(
|
|
Object.entries(baseline).map(([key, value]) => [
|
|
key,
|
|
syncTranslationValue(existingObject[key], value),
|
|
])
|
|
)
|
|
}
|
|
|
|
if (existing === undefined || existing === null) {
|
|
return baseline
|
|
}
|
|
|
|
return typeof existing === typeof baseline ? existing : baseline
|
|
}
|
|
|
|
function contentJsonString(content: unknown): string {
|
|
return JSON.stringify(content)
|
|
}
|
|
|
|
const mergeLocale = (...parts: LocaleData[]): LocaleData =>
|
|
parts.reduce((acc, part) => ({ ...acc, ...part }), {})
|
|
|
|
export class TranslationService {
|
|
private locales: Locales = {}
|
|
private loaded = false
|
|
|
|
constructor(private readonly kysely: Kysely<KyselyDB>) {}
|
|
|
|
private async ensureSnapshotSynced(): Promise<void> {
|
|
const existingRows = await this.kysely
|
|
.selectFrom('backendTranslation')
|
|
.select(['locale', 'namespace', 'contentJson'])
|
|
.execute() as BackendTranslationRow[]
|
|
|
|
const existingMap = new Map<string, BackendTranslationRow>(
|
|
existingRows.map((row) => [`${row.locale}:${row.namespace}`, row] as const)
|
|
)
|
|
const snapshotMap = new Map<string, (typeof backendTranslationSnapshot)[number]>(
|
|
backendTranslationSnapshot.map((row) => [
|
|
`${row.locale}:${row.namespace}`,
|
|
row,
|
|
] as const)
|
|
)
|
|
|
|
for (const snapshotRow of backendTranslationSnapshot) {
|
|
const key = `${snapshotRow.locale}:${snapshotRow.namespace}`
|
|
const existingRow = existingMap.get(key)
|
|
|
|
if (!existingRow) {
|
|
await this.kysely
|
|
.insertInto('backendTranslation')
|
|
.values({
|
|
locale: snapshotRow.locale,
|
|
namespace: snapshotRow.namespace,
|
|
contentJson: contentJsonString(snapshotRow.content),
|
|
})
|
|
.execute()
|
|
continue
|
|
}
|
|
|
|
const currentContent = parseLocaleData(existingRow.contentJson)
|
|
const syncedContent = syncTranslationValue(currentContent, snapshotRow.content)
|
|
|
|
if (contentJsonString(currentContent) === contentJsonString(syncedContent)) {
|
|
continue
|
|
}
|
|
|
|
await this.kysely
|
|
.updateTable('backendTranslation')
|
|
.set({
|
|
contentJson: contentJsonString(syncedContent),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where('locale', '=', snapshotRow.locale)
|
|
.where('namespace', '=', snapshotRow.namespace)
|
|
.execute()
|
|
}
|
|
|
|
for (const existingRow of existingRows) {
|
|
const key = `${existingRow.locale}:${existingRow.namespace}`
|
|
if (snapshotMap.has(key)) continue
|
|
|
|
await this.kysely
|
|
.deleteFrom('backendTranslation')
|
|
.where('locale', '=', existingRow.locale)
|
|
.where('namespace', '=', existingRow.namespace)
|
|
.execute()
|
|
}
|
|
}
|
|
|
|
async init(): Promise<void> {
|
|
if (this.loaded) return
|
|
|
|
await this.ensureSnapshotSynced()
|
|
|
|
const rows = await this.kysely
|
|
.selectFrom('backendTranslation')
|
|
.select(['locale', 'contentJson'])
|
|
.orderBy('locale')
|
|
.orderBy('namespace')
|
|
.execute() as Array<Pick<BackendTranslationRow, 'locale' | 'contentJson'>>
|
|
|
|
const locales: Locales = {}
|
|
for (const row of rows) {
|
|
locales[row.locale] = mergeLocale(
|
|
locales[row.locale] ?? {},
|
|
parseLocaleData(row.contentJson)
|
|
)
|
|
}
|
|
|
|
this.locales = locales
|
|
this.loaded = true
|
|
}
|
|
|
|
get(locale: string): LocaleData {
|
|
if (!this.loaded) {
|
|
throw new Error('TranslationService not loaded. Call init() first.')
|
|
}
|
|
|
|
if (!this.locales[locale]) {
|
|
console.warn(`Missing locale: ${locale}`)
|
|
}
|
|
|
|
return this.locales[locale] || this.locales.en || {}
|
|
}
|
|
|
|
getAll(): Locales {
|
|
if (!this.loaded) {
|
|
throw new Error('TranslationService not loaded. Call init() first.')
|
|
}
|
|
|
|
return this.locales
|
|
}
|
|
|
|
t(
|
|
path: TranslationTokens,
|
|
locale: string,
|
|
variables: Record<string, any> = {}
|
|
): string {
|
|
const localeData = this.get(locale)
|
|
const keys = path.split('.')
|
|
let value: any = localeData
|
|
|
|
for (const key of keys) {
|
|
if (value && typeof value === 'object' && key in value) {
|
|
value = value[key]
|
|
} else {
|
|
return `[Missing: ${path}]`
|
|
}
|
|
}
|
|
|
|
if (typeof value !== 'string') {
|
|
return `[Invalid: ${path}]`
|
|
}
|
|
|
|
return value.replace(/\{\{(\w+)\}\}|\{(\w+)\}/g, (_, keyA, keyB) => {
|
|
const key = keyA ?? keyB
|
|
return variables[key] || `{${key}}`
|
|
})
|
|
}
|
|
|
|
tArray(path: string, locale: string = 'en'): string[] {
|
|
const localeData = this.get(locale)
|
|
const keys = path.split('.')
|
|
let value: any = localeData
|
|
|
|
for (const key of keys) {
|
|
if (value && typeof value === 'object' && key in value) {
|
|
value = value[key]
|
|
} else {
|
|
return [`[Missing: ${path}]`]
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(value)) {
|
|
return [`[Invalid array: ${path}]`]
|
|
}
|
|
|
|
return value
|
|
}
|
|
}
|