#!/usr/bin/env node /** * Generates i18n maps for the native mobile app from the web source of truth. * * Source of truth: `frontend/src/i18n/locales/*.json` * * Output: * - `mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/i18n/MobileStrings.kt` * * Notes: * - German (`de`) is the canonical key set. * - Missing locale keys fall back to German at generation time so every mobile locale is complete. */ const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const SOURCE_DIR = path.join(ROOT, 'frontend', 'src', 'i18n', 'locales'); const OUT = path.join( ROOT, 'mobile-app', 'shared', 'src', 'commonMain', 'kotlin', 'de', 'tt_tagebuch', 'shared', 'i18n', 'MobileStrings.kt' ); const LOCALE_LABELS = { 'de': 'Deutsch', 'de-CH': 'Deutsch (Schweiz)', 'de-extended': 'Deutsch (erweitert)', 'en-US': 'English (US)', 'en-GB': 'English (UK)', 'en-AU': 'English (AU)', 'es': 'Español', 'fr': 'Français', 'it': 'Italiano', 'pl': 'Polski', 'ja': '日本語', 'th': 'ไทย', 'tl': 'Tagalog', 'fil': 'Filipino', 'zh': '中文', }; const MOBILE_STRINGS = { de: { 'mobile.add': 'Hinzufügen', 'mobile.appLoading': 'App wird geladen', 'mobile.cancel': 'Abbrechen', 'mobile.clubRequest': 'Zugriff anfragen', 'mobile.clubRequested': 'Angefragt', 'mobile.createDiaryEntry': 'Eintrag erstellen', 'mobile.deleteNote': 'Notiz löschen', 'mobile.deleteTag': 'Tag entfernen', 'mobile.editTimes': 'Zeiten bearbeiten', 'mobile.emailAndPasswordRequired': 'E-Mail und Passwort sind erforderlich', 'mobile.entry': 'Eintrag', 'mobile.language': 'Sprache', 'mobile.lastTraining': 'Letztes Training', 'mobile.loginInProgress': 'Anmelden...', 'mobile.loginFailed': 'Login fehlgeschlagen', 'mobile.more': 'Mehr', 'mobile.new': 'Neu', 'mobile.newNote': 'Neue Notiz', 'mobile.newTag': 'Neuer Tag', 'mobile.noDiaryEntries': 'Noch keine Tagebuch-Einträge', 'mobile.noMembers': 'Keine Mitglieder gefunden', 'mobile.noResults': 'Keine Treffer', 'mobile.noTimes': 'Keine Zeiten', 'mobile.participationTop': 'Top Teilnahmen', 'mobile.refresh': 'Aktualisieren', 'mobile.requestedAccess': 'Angefragt', 'mobile.role': 'Rolle', 'mobile.saveEntry': 'Speichern', 'mobile.search': 'Suche', 'mobile.select': 'Auswählen', 'mobile.sessionCheck': 'Session prüfen', 'mobile.sessionCheckFailed': 'Session check fehlgeschlagen', 'mobile.sessionInvalid': 'Session ungültig', 'mobile.sessionValid': 'Session gültig', 'mobile.status': 'Status', 'mobile.active': 'Aktiv', 'mobile.inactive': 'Inaktiv', 'mobile.user': 'Benutzer', }, en: { 'mobile.add': 'Add', 'mobile.appLoading': 'Loading app', 'mobile.cancel': 'Cancel', 'mobile.clubRequest': 'Request access', 'mobile.clubRequested': 'Requested', 'mobile.createDiaryEntry': 'Create entry', 'mobile.deleteNote': 'Delete note', 'mobile.deleteTag': 'Remove tag', 'mobile.editTimes': 'Edit times', 'mobile.emailAndPasswordRequired': 'Email and password are required', 'mobile.entry': 'Entry', 'mobile.language': 'Language', 'mobile.lastTraining': 'Last training', 'mobile.loginInProgress': 'Signing in...', 'mobile.loginFailed': 'Login failed', 'mobile.more': 'More', 'mobile.new': 'New', 'mobile.newNote': 'New note', 'mobile.newTag': 'New tag', 'mobile.noDiaryEntries': 'No diary entries yet', 'mobile.noMembers': 'No members found', 'mobile.noResults': 'No results', 'mobile.noTimes': 'No times', 'mobile.participationTop': 'Top attendance', 'mobile.refresh': 'Refresh', 'mobile.requestedAccess': 'Requested', 'mobile.role': 'Role', 'mobile.saveEntry': 'Save', 'mobile.search': 'Search', 'mobile.select': 'Select', 'mobile.sessionCheck': 'Check session', 'mobile.sessionCheckFailed': 'Session check failed', 'mobile.sessionInvalid': 'Session invalid', 'mobile.sessionValid': 'Session valid', 'mobile.status': 'Status', 'mobile.active': 'Active', 'mobile.inactive': 'Inactive', 'mobile.user': 'User', }, }; function flatten(obj, prefix = '', out = {}) { for (const [key, value] of Object.entries(obj || {})) { const nextKey = prefix ? `${prefix}.${key}` : key; if (value && typeof value === 'object' && !Array.isArray(value)) { flatten(value, nextKey, out); } else if (typeof value === 'string') { out[nextKey] = value; } } return out; } function escapeKotlinString(value) { return value .replace(/\\/g, '\\\\') .replace(/"/g, '\\"') .replace(/\r/g, '\\r') .replace(/\n/g, '\\n'); } function kotlinIdentifier(code) { return code.replace(/[^A-Za-z0-9_]/g, '_'); } function main() { const baseJson = JSON.parse(fs.readFileSync(path.join(SOURCE_DIR, 'de.json'), 'utf8')); const baseFlat = { ...flatten(baseJson), ...MOBILE_STRINGS.de, }; const localeFiles = fs .readdirSync(SOURCE_DIR) .filter((file) => file.endsWith('.json') && !file.endsWith('.backup')) .sort((a, b) => a.localeCompare(b)); const lines = []; lines.push('package de.tsschulz.tt_tagebuch.shared.i18n'); lines.push(''); lines.push('data class SupportedLanguage(val code: String, val label: String)'); lines.push(''); lines.push('object MobileStrings {'); lines.push(' const val DEFAULT_LANGUAGE = "de"'); lines.push(''); lines.push(' val supportedLanguages: List = listOf('); for (const file of localeFiles) { const code = path.basename(file, '.json'); lines.push(` SupportedLanguage("${escapeKotlinString(code)}", "${escapeKotlinString(LOCALE_LABELS[code] || code)}"),`); } lines.push(' )'); lines.push(''); const baseEntries = Object.entries(baseFlat).sort(([a], [b]) => a.localeCompare(b)); for (const file of localeFiles) { const code = path.basename(file, '.json'); const localeJson = JSON.parse(fs.readFileSync(path.join(SOURCE_DIR, file), 'utf8')); const specificMobileStrings = MOBILE_STRINGS[code] || (code.startsWith('en-') ? MOBILE_STRINGS.en : {}); const flat = { ...baseFlat, ...flatten(localeJson), ...specificMobileStrings, }; lines.push(` private val ${kotlinIdentifier(code)}: Map by lazy { mapOf(`); for (const [key, value] of Object.entries(flat).sort(([a], [b]) => a.localeCompare(b))) { lines.push(` "${escapeKotlinString(key)}" to "${escapeKotlinString(value)}",`); } lines.push(' ) }'); lines.push(''); } lines.push(` private val fallback: Map get() = ${kotlinIdentifier('de')}`); lines.push(''); lines.push(' fun get(languageCode: String, key: String, fallbackValue: String): String ='); lines.push(' mapFor(languageCode)[key] ?: fallback[key] ?: fallbackValue'); lines.push(''); lines.push(' private fun mapFor(languageCode: String): Map = when (languageCode) {'); for (const file of localeFiles) { const code = path.basename(file, '.json'); lines.push(` "${escapeKotlinString(code)}" -> ${kotlinIdentifier(code)}`); } lines.push(' else -> fallback'); lines.push(' }'); lines.push('}'); lines.push(''); fs.mkdirSync(path.dirname(OUT), { recursive: true }); fs.writeFileSync(OUT, lines.join('\n'), 'utf8'); console.log(`Wrote ${OUT} (${localeFiles.length} locales, ${baseEntries.length} keys each)`); } main();