Files
neta/server/i18n/service.ts
T

505 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { LocaleStatus, TextDirection } from "../db/schema";
import {
DEFAULT_LOCALE,
I18N_NAMESPACES,
compareCatalogKeys,
flattenCatalog,
getBuiltInCatalog,
type I18nNamespace,
} from "../../lib/i18n";
import { trCatalog } from "../../locales/tr";
import { requireOwnerScope, type DomainActor } from "../domain/actor";
import type { DomainDatabase } from "../domain/database";
import { DomainError, notFound } from "../domain/errors";
import { createI18nRepository } from "../repositories/i18n";
export type LocaleRecord = {
code: string;
name: string;
nativeName: string;
status: LocaleStatus;
fallbackLocale: string | null;
textDirection: TextDirection;
builtIn: boolean;
sortOrder: number;
};
export type I18nSettingsRecord = {
defaultLocale: string;
catalogVersion: number;
};
export type CreateLocaleInput = {
code: string;
name: string;
nativeName?: string;
fallbackLocale?: string | null;
textDirection?: TextDirection;
status?: LocaleStatus;
sortOrder?: number;
};
export type UpdateLocaleInput = Partial<Omit<CreateLocaleInput, "code">>;
export type UpsertUiTranslationInput = {
locale: string;
namespace: string;
key: string;
value: string;
};
export type UiTranslationRow = {
locale: string;
namespace: string;
key: string;
value: string;
};
export type I18nExportPackage = {
format: "neta-i18n";
version: 1;
exportedAt: string;
defaultLocale: string;
locales: LocaleRecord[];
translations: UiTranslationRow[];
};
export type TranslationCompletion = {
locale: string;
translated: number;
total: number;
percent: number;
missingKeys: string[];
};
const LOCALE_CODE_PATTERN = /^[a-z]{2}(?:-[A-Z]{2}[0-9]?)?$/;
const BUILT_IN_LOCALES = new Set(["tr", "en"]);
export class I18nService {
private readonly repository;
constructor(private readonly db: DomainDatabase) {
this.repository = createI18nRepository(db);
}
listLocales(actor: DomainActor): LocaleRecord[] {
requireOwnerScope(actor);
this.ensureBootstrap();
return this.repository.listLocales().map(toLocaleRecord);
}
getSettings(actor: DomainActor): I18nSettingsRecord {
requireOwnerScope(actor);
this.ensureBootstrap();
return toSettingsRecord(this.repository.getSettings());
}
createLocale(actor: DomainActor, input: CreateLocaleInput): LocaleRecord {
requireOwnerScope(actor);
this.ensureBootstrap();
const code = normalizeLocaleCode(input.code);
if (BUILT_IN_LOCALES.has(code)) {
throw new DomainError("CONFLICT", "Built-in dil zaten mevcut.");
}
if (this.repository.getLocale(code)) {
throw new DomainError("CONFLICT", "Bu dil zaten eklenmiş.");
}
const fallbackLocale = normalizeOptionalLocale(input.fallbackLocale ?? "en");
this.assertValidFallback(code, fallbackLocale);
const created = this.repository.createLocale({
code,
name: normalizeRequiredText(input.name, "Dil adı zorunludur."),
nativeName: normalizeRequiredText(input.nativeName ?? input.name, "Yerel dil adı zorunludur."),
fallbackLocale,
textDirection: input.textDirection ?? "ltr",
status: input.status ?? "draft",
builtIn: false,
sortOrder: input.sortOrder ?? 100,
});
return toLocaleRecord(created);
}
updateLocale(actor: DomainActor, code: string, input: UpdateLocaleInput): LocaleRecord {
requireOwnerScope(actor);
this.ensureBootstrap();
const locale = this.getExistingLocale(code);
if (locale.builtIn && input.status === "archived") {
throw new DomainError("CONFLICT", "Built-in diller arşivlenemez.");
}
const nextFallback = input.fallbackLocale === undefined
? locale.fallbackLocale
: normalizeOptionalLocale(input.fallbackLocale);
this.assertValidFallback(locale.code, nextFallback);
const nextStatus = input.status ?? locale.status;
if (nextStatus === "archived") {
this.assertLocaleCanBeArchived(locale.code);
}
const updated = this.repository.updateLocale(locale.code, {
name: input.name === undefined ? undefined : normalizeRequiredText(input.name, "Dil adı zorunludur."),
nativeName: input.nativeName === undefined
? undefined
: normalizeRequiredText(input.nativeName, "Yerel dil adı zorunludur."),
fallbackLocale: nextFallback,
textDirection: input.textDirection,
status: nextStatus,
sortOrder: input.sortOrder,
});
if (!updated) throw notFound("Dil");
return toLocaleRecord(updated);
}
archiveLocale(actor: DomainActor, code: string): LocaleRecord {
return this.updateLocale(actor, code, { status: "archived" });
}
setDefaultLocale(actor: DomainActor, code: string): I18nSettingsRecord {
requireOwnerScope(actor);
this.ensureBootstrap();
const locale = this.getExistingLocale(code);
if (locale.status !== "active") {
throw new DomainError("VALIDATION_ERROR", "Varsayılan dil yalnızca aktif bir dil olabilir.");
}
const updated = this.repository.updateDefaultLocale(locale.code);
return toSettingsRecord(updated);
}
upsertUiTranslation(actor: DomainActor, input: UpsertUiTranslationInput): void {
requireOwnerScope(actor);
this.ensureBootstrap();
const locale = this.getExistingLocale(input.locale);
if (locale.status === "archived") {
throw new DomainError("VALIDATION_ERROR", "Arşivlenmiş dile çeviri yazılamaz.");
}
const namespace = normalizeIdentifier(input.namespace, "Namespace geçersiz.", 64);
const translationKey = normalizeIdentifier(input.key, "Çeviri anahtarı geçersiz.", 160);
const value = input.value.trim();
if (!value) {
throw new DomainError("VALIDATION_ERROR", "Çeviri metni boş olamaz.");
}
this.repository.upsertUiTranslation({
locale: locale.code,
namespace,
translationKey,
value,
});
this.repository.bumpCatalogVersion();
}
resetUiTranslation(actor: DomainActor, input: Omit<UpsertUiTranslationInput, "value">): void {
requireOwnerScope(actor);
this.ensureBootstrap();
const locale = this.getExistingLocale(input.locale);
this.repository.deleteUiTranslation(
locale.code,
normalizeIdentifier(input.namespace, "Namespace geçersiz.", 64),
normalizeIdentifier(input.key, "Çeviri anahtarı geçersiz.", 160),
);
this.repository.bumpCatalogVersion();
}
listUiTranslations(actor: DomainActor): UiTranslationRow[] {
requireOwnerScope(actor);
this.ensureBootstrap();
return this.repository.listAllUiTranslations().map((row) => ({
locale: row.locale,
namespace: row.namespace,
key: row.translationKey,
value: row.value,
}));
}
getCompletion(actor: DomainActor): TranslationCompletion[] {
requireOwnerScope(actor);
this.ensureBootstrap();
const referenceKeys = Object.keys(flattenCatalog(trCatalog, I18N_NAMESPACES));
const overrideRows = this.repository.listAllUiTranslations();
const overrideKeysByLocale = new Map<string, Set<string>>();
for (const row of overrideRows) {
const keys = overrideKeysByLocale.get(row.locale) ?? new Set<string>();
keys.add(`${row.namespace}.${row.translationKey}`);
overrideKeysByLocale.set(row.locale, keys);
}
return this.repository.listLocales().map((locale) => {
const builtIn = getBuiltInCatalog(locale.code);
const builtInKeys = builtIn ? new Set(Object.keys(flattenCatalog(builtIn, I18N_NAMESPACES))) : new Set<string>();
const overrideKeys = overrideKeysByLocale.get(locale.code) ?? new Set<string>();
const missingKeys = referenceKeys.filter((key) => !builtInKeys.has(key) && !overrideKeys.has(key));
const translated = referenceKeys.length - missingKeys.length;
return {
locale: locale.code,
translated,
total: referenceKeys.length,
percent: referenceKeys.length ? Math.round((translated / referenceKeys.length) * 100) : 100,
missingKeys,
};
});
}
exportPackage(actor: DomainActor): I18nExportPackage {
requireOwnerScope(actor);
this.ensureBootstrap();
return {
format: "neta-i18n",
version: 1,
exportedAt: new Date().toISOString(),
defaultLocale: this.getSettings(actor).defaultLocale,
locales: this.listLocales(actor),
translations: this.listUiTranslations(actor),
};
}
importPackage(actor: DomainActor, input: unknown): I18nExportPackage {
requireOwnerScope(actor);
this.ensureBootstrap();
const parsed = parseImportPackage(input);
for (const locale of parsed.locales) {
if (!this.repository.getLocale(locale.code) && !BUILT_IN_LOCALES.has(locale.code)) {
this.createLocale(actor, {
code: locale.code,
name: locale.name,
nativeName: locale.nativeName,
fallbackLocale: locale.fallbackLocale ?? DEFAULT_LOCALE,
textDirection: locale.textDirection,
status: locale.status === "archived" ? "draft" : locale.status,
sortOrder: locale.sortOrder,
});
}
}
for (const translation of parsed.translations) {
this.upsertUiTranslation(actor, translation);
}
const defaultLocale = parsed.defaultLocale ? this.repository.getLocale(parsed.defaultLocale) : null;
if (defaultLocale?.status === "active") {
this.setDefaultLocale(actor, defaultLocale.code);
}
return this.exportPackage(actor);
}
private ensureBootstrap(): void {
this.repository.createSettingsIfMissing();
ensureBuiltInLocale(this.repository, {
code: "tr",
name: "Turkish",
nativeName: "Türkçe",
status: "active",
fallbackLocale: null,
textDirection: "ltr",
builtIn: true,
sortOrder: 10,
});
ensureBuiltInLocale(this.repository, {
code: "en",
name: "English",
nativeName: "English",
status: "active",
fallbackLocale: "tr",
textDirection: "ltr",
builtIn: true,
sortOrder: 20,
});
}
private getExistingLocale(code: string) {
const normalized = normalizeLocaleCode(code);
const locale = this.repository.getLocale(normalized);
if (!locale) throw notFound("Dil");
return locale;
}
private assertValidFallback(code: string, fallbackLocale: string | null): void {
if (!fallbackLocale) return;
if (fallbackLocale === code) {
throw new DomainError("VALIDATION_ERROR", "Dil kendi kendine fallback olamaz.");
}
const fallback = this.repository.getLocale(fallbackLocale);
if (!fallback || fallback.status === "archived") {
throw new DomainError("VALIDATION_ERROR", "Fallback dili aktif veya taslak bir dil olmalıdır.");
}
const graph = new Map(
this.repository
.listLocales()
.map((locale) => [locale.code, locale.fallbackLocale] as const),
);
graph.set(code, fallbackLocale);
const seen = new Set<string>();
let cursor: string | null = fallbackLocale;
while (cursor) {
if (cursor === code || seen.has(cursor)) {
throw new DomainError("VALIDATION_ERROR", "Fallback zinciri döngü oluşturamaz.");
}
seen.add(cursor);
cursor = graph.get(cursor) ?? null;
}
}
private assertLocaleCanBeArchived(code: string): void {
const references = this.repository.countLocaleReferences(code);
const referenceCount = Object.values(references).reduce((total, value) => total + value, 0);
if (referenceCount > 0) {
throw new DomainError("CONFLICT", "Kullanımda olan dil arşivlenemez.", references);
}
}
}
export function getReferenceTranslationKeys(namespace: I18nNamespace | "all" = "all") {
const namespaces = namespace === "all" ? I18N_NAMESPACES : [namespace];
const tr = flattenCatalog(trCatalog, namespaces);
const en = flattenCatalog(getBuiltInCatalog("en") ?? trCatalog, namespaces);
const parity = compareCatalogKeys(trCatalog, getBuiltInCatalog("en") ?? trCatalog, I18N_NAMESPACES);
return Object.keys(tr).sort().map((key) => ({
key,
namespace: key.split(".")[0] as I18nNamespace,
translationKey: key.split(".").slice(1).join("."),
tr: tr[key] ?? "",
en: en[key] ?? "",
parityOk: parity.missingInLeft.length === 0 && parity.missingInRight.length === 0,
}));
}
function ensureBuiltInLocale(
repository: ReturnType<typeof createI18nRepository>,
value: Parameters<ReturnType<typeof createI18nRepository>["createLocale"]>[0],
): void {
const existing = repository.getLocale(value.code);
if (!existing) {
repository.createLocale(value);
return;
}
repository.updateLocale(value.code, {
name: value.name,
nativeName: value.nativeName,
status: value.status,
fallbackLocale: value.fallbackLocale,
textDirection: value.textDirection,
builtIn: true,
sortOrder: value.sortOrder,
});
}
function normalizeLocaleCode(code: string): string {
const normalized = code.trim();
if (!LOCALE_CODE_PATTERN.test(normalized)) {
throw new DomainError("VALIDATION_ERROR", "Dil kodu BCP47 kısa formatında olmalıdır. Örn: tr, en, fr veya ar-XB.");
}
return normalized;
}
function normalizeOptionalLocale(code: string | null | undefined): string | null {
if (!code) return null;
return normalizeLocaleCode(code);
}
function normalizeRequiredText(value: string, message: string): string {
const normalized = value.trim();
if (!normalized) throw new DomainError("VALIDATION_ERROR", message);
if (normalized.length > 80) {
throw new DomainError("VALIDATION_ERROR", "Dil adı 80 karakterden kısa olmalıdır.");
}
return normalized;
}
function normalizeIdentifier(value: string, message: string, maxLength: number): string {
const normalized = value.trim();
if (!normalized || normalized.length > maxLength || !/^[a-zA-Z0-9_.-]+$/.test(normalized)) {
throw new DomainError("VALIDATION_ERROR", message);
}
return normalized;
}
function toLocaleRecord(value: {
code: string;
name: string;
nativeName: string;
status: LocaleStatus;
fallbackLocale: string | null;
textDirection: TextDirection;
builtIn: boolean;
sortOrder: number;
}): LocaleRecord {
return {
code: value.code,
name: value.name,
nativeName: value.nativeName,
status: value.status,
fallbackLocale: value.fallbackLocale,
textDirection: value.textDirection,
builtIn: value.builtIn,
sortOrder: value.sortOrder,
};
}
function toSettingsRecord(value: {
defaultLocale: string;
catalogVersion: number;
} | undefined): I18nSettingsRecord {
if (!value) {
throw new DomainError("INVARIANT_VIOLATION", "I18n ayarları oluşturulamadı.");
}
return {
defaultLocale: value.defaultLocale,
catalogVersion: value.catalogVersion,
};
}
function parseImportPackage(input: unknown): I18nExportPackage {
if (!input || typeof input !== "object") {
throw new DomainError("VALIDATION_ERROR", "Import paketi geçersiz.");
}
const value = input as Partial<I18nExportPackage>;
if (value.format !== "neta-i18n" || value.version !== 1) {
throw new DomainError("VALIDATION_ERROR", "Import paketi desteklenmiyor.");
}
if (!Array.isArray(value.locales) || !Array.isArray(value.translations)) {
throw new DomainError("VALIDATION_ERROR", "Import paketinde locale veya çeviri listesi eksik.");
}
return {
format: "neta-i18n",
version: 1,
exportedAt: typeof value.exportedAt === "string" ? value.exportedAt : new Date().toISOString(),
defaultLocale: typeof value.defaultLocale === "string" ? value.defaultLocale : DEFAULT_LOCALE,
locales: value.locales.map((locale) => ({
code: normalizeLocaleCode(locale.code),
name: normalizeRequiredText(locale.name, "Dil adı zorunludur."),
nativeName: normalizeRequiredText(locale.nativeName, "Yerel dil adı zorunludur."),
status: locale.status,
fallbackLocale: normalizeOptionalLocale(locale.fallbackLocale),
textDirection: locale.textDirection === "rtl" ? "rtl" : "ltr",
builtIn: Boolean(locale.builtIn),
sortOrder: Number.isFinite(locale.sortOrder) ? locale.sortOrder : 100,
})),
translations: value.translations.map((translation) => ({
locale: normalizeLocaleCode(translation.locale),
namespace: normalizeIdentifier(translation.namespace, "Namespace geçersiz.", 64),
key: normalizeIdentifier(translation.key, "Çeviri anahtarı geçersiz.", 160),
value: normalizeRequiredText(translation.value, "Çeviri metni boş olamaz."),
})),
};
}