Files
heygermany-e2e-mrg5yqhb/packages/functions/src/services/translation-service.ts
e2e f0093328d8
Some checks failed
Main / Setup and Test (push) Has been cancelled
Main / Build Website (push) Has been cancelled
chore: heygermany customer project
2026-07-11 11:30:11 +02:00

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
}
}