diff --git a/.gitignore b/.gitignore index 568664f..05ed680 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ frontend/node_modules frontend/node_modules/* frontend/dist frontend/dist/* +frontend/scripts/.i18n-de-fr-cache.json +frontend/scripts/.falukant-fr-smooth-cache.json frontedtree.txt backend/dist/ backend/data/model-cache diff --git a/backend/migrations/20260402120000-add-ui-locale-fr-user-param-value.cjs b/backend/migrations/20260402120000-add-ui-locale-fr-user-param-value.cjs new file mode 100644 index 0000000..434b874 --- /dev/null +++ b/backend/migrations/20260402120000-add-ui-locale-fr-user-param-value.cjs @@ -0,0 +1,30 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + INSERT INTO type.user_param_value (user_param_type_id, value, order_id) + SELECT upt.id, 'fr', COALESCE( + (SELECT MAX(v.order_id) FROM type.user_param_value v WHERE v.user_param_type_id = upt.id), + 0 + ) + 1 + FROM type.user_param upt + WHERE upt.description = 'language' + AND NOT EXISTS ( + SELECT 1 FROM type.user_param_value x + WHERE x.user_param_type_id = upt.id AND x.value = 'fr' + ); + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DELETE FROM type.user_param_value v + USING type.user_param upt + WHERE v.user_param_type_id = upt.id + AND upt.description = 'language' + AND v.value = 'fr'; + `); + }, +}; diff --git a/backend/services/authService.js b/backend/services/authService.js index 78af60a..1c45785 100644 --- a/backend/services/authService.js +++ b/backend/services/authService.js @@ -144,7 +144,7 @@ export const loginUser = async ({ username, password }) => { const mappedParams = params.map(param => { return { 'name': param.paramType.description, 'value': param.value }; }); - const uiLocaleCodes = ['de', 'en', 'ceb', 'es']; + const uiLocaleCodes = ['de', 'en', 'ceb', 'es', 'fr']; const langEntry = mappedParams.find((p) => p.name === 'language'); if (langEntry?.value && !uiLocaleCodes.includes(langEntry.value)) { const idNum = parseInt(langEntry.value, 10); diff --git a/backend/utils/initializeTypes.js b/backend/utils/initializeTypes.js index a4e15c1..9a2dec7 100644 --- a/backend/utils/initializeTypes.js +++ b/backend/utils/initializeTypes.js @@ -67,7 +67,7 @@ const initializeTypes = async () => { } const valuesList = { gender: ['male', 'female', 'transfemale', 'transmale', 'nonbinary'], - language: ['de', 'en', 'ceb', 'es'], + language: ['de', 'en', 'ceb', 'es', 'fr'], eyecolor: ['blue', 'green', 'brown', 'black', 'grey', 'hazel', 'amber', 'red', 'other'], haircolor: ['black', 'brown', 'blonde', 'red', 'grey', 'white', 'other'], hairlength: ['short', 'medium', 'long', 'bald', 'other'], diff --git a/docs/KONZEPT_UI_SPRACHE_FRANZOESISCH.md b/docs/KONZEPT_UI_SPRACHE_FRANZOESISCH.md new file mode 100644 index 0000000..7d63c68 --- /dev/null +++ b/docs/KONZEPT_UI_SPRACHE_FRANZOESISCH.md @@ -0,0 +1,113 @@ +# Konzept: Französisch (`fr`) als Bedien-/UI-Sprache + +**Stand:** April 2026 +**Bezug:** Bestehende Locales `de`, `en`, `es`, `ceb` (Vue I18n, Store, SEO, Header). + +--- + +## 1. Ziel + +- Nutzer können die Oberfläche auf **Französisch** stellen (Endonym: **Français**). +- **Vollständige Key-Parität** mit der deutschen Referenz (`de`), analog zu **Spanisch** (`es`): keine stillen Lücken, die nur über Fallback sichtbar werden. +- **Locale-Code:** `fr` (BCP 47). Regionale Varianten (`fr-CA`, `fr-CH`) nur bei Bedarf später; vorerst eine gemeinsame `fr`-UI. + +--- + +## 2. Inhaltliche Leitplanken + +| Thema | Empfehlung | +|--------|------------| +| **Anrede** | Einheitlich festlegen: **„vous“** (formell) oder **„tu“** (duzt); für eine Community-Plattform oft **vous** bei Systemtexten, ggf. Ausnahmen in Spieldialogen abstimmen. **Umsetzung:** Die erste französische UI-Version wurde maschinell aus dem Deutschen erzeugt (`google-translate-api-x`); menschliche Nachbearbeitung sollte **vous** durchgängig prüfen (v. a. Social/Vocab/Falukant). | +| **Referenz für Keys** | `de` als maßgebliche Struktur; Übersetzung nach Französisch. | +| **Qualität** | Große Module (Falukant, Admin, Social/Vocab) iterativ oder durch TMS/Übersetzer; CI-Check auf Key-Parität `de` ↔ `fr`. | + +--- + +## 3. Technische Umsetzung (Frontend) + +### 3.1 Neue Übersetzungsdateien + +- Ordner: `frontend/src/i18n/locales/fr/` +- Dieselben **19 JSON-Dateien** wie unter `en` / `de` / `es` / `ceb` (z. B. `general.json`, `header.json`, … `seo.json`). + +### 3.2 I18n-Bundling + +- Datei: `frontend/src/i18n/index.js` + - Alle `fr*` importieren. + - Block `messages.fr = { ...frGeneral, …, ...frSeo }` analog zu `es`. + - **`fallbackLocale`:** gesetzt als **`fr: ['de']`** (Lücken fallen auf Deutsch zurück). +- **`ceb`** nutzt `deepMerge` über `en`+`ceb`; **`fr`** wie **`es`**: nur `fr`-Bundles, kein deepMerge nötig. + +### 3.3 Zentrale Liste unterstützter UI-Locales + +Aktuell mehrfach dieselbe Liste (`de`, `en`, `ceb`, `es`), u. a. in: + +- `frontend/src/store/index.js` +- `frontend/src/main.js` (`?lang=`, localStorage) +- `frontend/src/components/AppHeader.vue` +- `frontend/src/components/SettingsWidget.vue` +- `frontend/src/utils/seo.js` + +**Empfehlung:** Eine exportierte Konstante z. B. `frontend/src/i18n/supportedLocales.js` (`SUPPORTED_UI_LOCALES`, ggf. `SEO_UI_LOCALES` daraus ableiten oder gemeinsam pflegen) und alle Stellen darauf umstellen – **vor oder beim** Einführen von `fr`, damit keine Stelle vergessen wird. + +### 3.4 Sprachauswahl UI + +- **`AppHeader.vue`:** Option `{ value: 'fr', nativeLabel: 'Français' }`; `supported` aus Zentralmodul. +- **`SettingsWidget.vue`:** Nach Speichern der Profil-Sprache Store synchron halten; `languagesList` aktuell unvollständig vs. Header – **alle** UI-Sprachen (`de`, `en`, `ceb`, `es`, `fr`) konsistent anbieten oder vollständig aus API-`setting.options` speisen. + +### 3.5 Register / Einstellungen (JSON) + +- In `settings.personal.language` (und falls nötig `register.languages`) in **allen** relevanten Locales den Eintrag **`fr`** / **„Français“** ergänzen. + +### 3.6 SEO & Einstieg per URL + +- **`frontend/src/utils/seo.js`:** `fr` in die Locale-Liste aufnehmen; `hreflang` für `fr`; `og:locale` / Alternates wie bei bestehenden Sprachen. +- **`main.js`:** Query `?lang=fr` über zentrale erlaubte Locales freischalten. +- **`index.html`:** falls hreflang/noscript manuell gepflegt wird – `fr` ergänzen. + +### 3.7 Browser-Sprache (optional) + +- In `store` / `main.js`: Wenn `navigator.language` mit `fr` beginnt (z. B. `fr`, `fr-FR`, `fr-BE`, `fr-CH`), Standard-UI auf `fr` setzen – analog zu bestehender Logik für Deutsch und Bisaya. + +--- + +## 4. Backend + +- Falls die **Account-Sprache** serverseitig validiert oder als feste Optionsliste ausgeliefert wird: **`fr`** zur Whitelist / zu den Optionen hinzufügen. +- **Vocab-Sprachen** in der Datenbank (Kurse, Zielsprachen) sind unabhängig von der **UI-Locale**; keine Pflichtänderung für reines UI-Französisch. + +--- + +## 5. Qualitätssicherung & Abnahme + +- Skript: flache Key-Parität `de` vs. `fr` pro JSON-Datei (wie bereits für `es` genutzt). +- `npm run build` ohne Fehler. +- Manuell: `?lang=fr`, Wechsel im Header ohne vollständigen Reload, Smoke über Login, Einstellungen, eine Social-/Falukant-Ansicht. + +--- + +## 6. Definition of Done (Checkliste) + +- [x] `frontend/src/i18n/locales/fr/*.json` vollständig (Key-Parität zu `de`) +- [x] `i18n/index.js`: `fr` eingebunden, `fallbackLocale` für `fr` gesetzt (`['de']`) +- [x] Zentrale `SUPPORTED_UI_LOCALES` in `frontend/src/i18n/supportedLocales.js`; Store, `main.js`, Header, Settings-Widget, `seo.js` importieren daraus +- [x] `AppHeader`, `main.js`, `store`, `seo.js`, `RegisterDialog` um `fr` / Browser-Erkennung `fr*` erweitert +- [x] `settings` / `register` JSON: Sprache `fr` (und Register um `es` wo gefehlt) ergänzt +- [x] `SettingsWidget`: `languagesList` über alle UI-Sprachen aus `SUPPORTED_UI_LOCALES` +- [x] Backend: `initializeTypes` + `authService` + Migration `type.user_param_value` für `fr` +- [x] SEO/hreflang/`?lang=fr` + `index.html` Alternate + noscript `lang="fr"` +- [x] QA: `npm run i18n:check-parity` (de↔fr), `npm run build` +- [x] Anrede / MT-Hinweis in Abschnitt 2 (Tabelle) und Skript `frontend/scripts/generate-fr-locale-from-de.mjs` für Regenerierung + +--- + +## 7. Nächste Schritte (optional als Tickets) + +1. **Infra:** `supportedLocales.js` + `fr` in allen technischen Stellen + leere/kopierte `fr`-JSONs aus `de` als Stub. +2. **SEO:** hreflang, `seo.json` `fr`, `main.js` / `index.html`. +3. **Übersetzung:** modulweise (Core → Social → Falukant → Admin). +4. **QA & CI:** Key-Check im Workflow. + +--- + +*Ende des Konzepts.* diff --git a/frontend/index.html b/frontend/index.html index 2ce889f..0cfff60 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -29,6 +29,7 @@ + @@ -56,6 +57,11 @@

Ang YourPart usa ka plataporma alang sa komunidad, chat, forum, blog, trainer sa bokabularyo, ang browser game nga Falukant ug minigames.

Mga bahin: Blogs, Bokabularyo, Falukant, Minigames.

+
+

YourPart (français)

+

YourPart est une plateforme pour la communauté, le chat, les forums, les blogs, l’entraînement au vocabulaire, le jeu de construction Falukant dans le navigateur et les mini-jeux.

+

Zones principales : Blogs, Vocabulaire, Falukant et Mini-jeux.

+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8d9622f..9dd0adf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -35,6 +35,7 @@ "@gltf-transform/cli": "^4.3.0", "@vitejs/plugin-vue": "^5.2.4", "assert": "^2.1.0", + "google-translate-api-x": "^10.7.2", "sass": "^1.98.0", "stream-browserify": "^3.0.0", "util": "^0.12.5", @@ -3736,6 +3737,20 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/google-translate-api-x": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/google-translate-api-x/-/google-translate-api-x-10.7.2.tgz", + "integrity": "sha512-GSmbvGMcnULaih2NFgD4Y6840DLAMot90mLWgwoB+FG/QpetyZkFrZkxop8ZxXgOAQXGskFOhGJady8nA6ZJ2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/AidanWelch" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3c0fa4b..b391555 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,10 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "optimize-models": "node scripts/optimize-glb.mjs" + "optimize-models": "node scripts/optimize-glb.mjs", + "i18n:check-parity": "node scripts/check-i18n-locale-parity.mjs de fr", + "i18n:translate-fr": "node scripts/generate-fr-locale-from-de.mjs", + "i18n:smooth-falukant-fr": "node scripts/smooth-fr-falukant.mjs" }, "dependencies": { "@tiptap/extension-color": "^2.27.2", @@ -36,6 +39,7 @@ "@gltf-transform/cli": "^4.3.0", "@vitejs/plugin-vue": "^5.2.4", "assert": "^2.1.0", + "google-translate-api-x": "^10.7.2", "sass": "^1.98.0", "stream-browserify": "^3.0.0", "util": "^0.12.5", diff --git a/frontend/scripts/check-i18n-locale-parity.mjs b/frontend/scripts/check-i18n-locale-parity.mjs new file mode 100644 index 0000000..e34784a --- /dev/null +++ b/frontend/scripts/check-i18n-locale-parity.mjs @@ -0,0 +1,78 @@ +/** + * Vergleicht flache Schlüsselpfade zwischen zwei Locale-Ordnern (gleiche JSON-Dateinamen). + * + * Aufruf: node scripts/check-i18n-locale-parity.mjs de fr + */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BASE = path.join(__dirname, '../src/i18n/locales'); + +function flattenLeaves(obj, prefix = '', out = {}) { + if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) { + out[prefix] = obj; + return out; + } + const keys = Object.keys(obj); + if (keys.length === 0) { + out[prefix] = obj; + return out; + } + for (const k of keys) { + const p = prefix ? `${prefix}.${k}` : k; + const v = obj[k]; + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + flattenLeaves(v, p, out); + } else { + out[p] = v; + } + } + return out; +} + +const [a, b] = process.argv.slice(2); +if (!a || !b) { + console.error('Usage: node scripts/check-i18n-locale-parity.mjs '); + process.exit(2); +} + +const dirA = path.join(BASE, a); +const dirB = path.join(BASE, b); +const files = fs.readdirSync(dirA).filter((f) => f.endsWith('.json')); +let errors = 0; + +for (const f of files) { + const pa = path.join(dirA, f); + const pb = path.join(dirB, f); + if (!fs.existsSync(pb)) { + console.error(`Fehlt: ${b}/${f}`); + errors++; + continue; + } + const ja = JSON.parse(fs.readFileSync(pa, 'utf8')); + const jb = JSON.parse(fs.readFileSync(pb, 'utf8')); + const fa = flattenLeaves(ja); + const fb = flattenLeaves(jb); + const ka = new Set(Object.keys(fa)); + const kb = new Set(Object.keys(fb)); + for (const k of ka) { + if (!kb.has(k)) { + console.error(`[${f}] fehlt in ${b}: ${k}`); + errors++; + } + } + for (const k of kb) { + if (!ka.has(k)) { + console.error(`[${f}] extra in ${b}: ${k}`); + errors++; + } + } +} + +if (errors) { + console.error(`\n${errors} Abweichung(en).`); + process.exit(1); +} +console.log(`OK: Key-Parität ${a} ↔ ${b} für ${files.length} Dateien.`); diff --git a/frontend/scripts/generate-fr-locale-from-de.mjs b/frontend/scripts/generate-fr-locale-from-de.mjs new file mode 100644 index 0000000..4d0ed82 --- /dev/null +++ b/frontend/scripts/generate-fr-locale-from-de.mjs @@ -0,0 +1,134 @@ +/** + * Übersetzt String-Blätter in locales/fr/*.json von Deutsch nach Französisch + * (google-translate-api-x, inoffiziell), mit Cache unter scripts/.i18n-de-fr-cache.json. + * + * Ausführen aus frontend/: node scripts/generate-fr-locale-from-de.mjs + */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { translate } from 'google-translate-api-x'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const LOCALES_FR = path.join(__dirname, '../src/i18n/locales/fr'); +const CACHE_PATH = path.join(__dirname, '.i18n-de-fr-cache.json'); +const CONCURRENCY = Math.max(1, parseInt(process.env.I18N_MT_CONCURRENCY || '4', 10)); +const DELAY_MS = Math.max(0, parseInt(process.env.I18N_MT_DELAY_MS || '350', 10)); + +function loadCache() { + try { + return JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8')); + } catch { + return {}; + } +} + +function saveCache(cache) { + fs.writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 0), 'utf8'); +} + +function shouldSkipTranslation(str) { + if (typeof str !== 'string' || str.length === 0) return true; + if (/^[\d\s./:\\\-–—:]+$/u.test(str) && str.length < 96) return true; + return false; +} + +function collectStrings(node, set) { + if (node === null || node === undefined) return; + if (typeof node === 'string') { + set.add(node); + return; + } + if (Array.isArray(node)) { + for (const x of node) collectStrings(x, set); + return; + } + if (typeof node === 'object') { + for (const k of Object.keys(node)) collectStrings(node[k], set); + } +} + +function applyTranslations(node, cache) { + if (node === null || node === undefined) return node; + if (typeof node === 'string') { + if (shouldSkipTranslation(node)) return node; + return cache[node] !== undefined ? cache[node] : node; + } + if (Array.isArray(node)) return node.map((x) => applyTranslations(x, cache)); + if (typeof node === 'object') { + const out = {}; + for (const k of Object.keys(node)) { + out[k] = applyTranslations(node[k], cache); + } + return out; + } + return node; +} + +async function translateOne(text, cache) { + if (shouldSkipTranslation(text)) return text; + if (cache[text]) return cache[text]; + for (let attempt = 0; attempt < 4; attempt++) { + try { + const res = await translate(text, { from: 'de', to: 'fr' }); + const out = res.text; + cache[text] = out; + return out; + } catch (e) { + await new Promise((r) => setTimeout(r, 900 * (attempt + 1))); + } + } + console.warn('Übersetzung fehlgeschlagen, Original behalten:', text.slice(0, 80)); + cache[text] = text; + return text; +} + +async function main() { + const files = fs.readdirSync(LOCALES_FR).filter((f) => f.endsWith('.json')); + const all = new Set(); + for (const f of files) { + const raw = JSON.parse(fs.readFileSync(path.join(LOCALES_FR, f), 'utf8')); + collectStrings(raw, all); + } + const unique = [...all].filter((s) => !shouldSkipTranslation(s)); + const cache = loadCache(); + const missing = unique.filter((s) => cache[s] === undefined); + console.log(`Unique (MT): ${unique.length}, ohne Cache-Eintrag: ${missing.length}`); + + let next = 0; + let saved = 0; + + async function worker() { + for (;;) { + const i = next++; + if (i >= missing.length) return; + const s = missing[i]; + await translateOne(s, cache); + saved++; + if (saved % 30 === 0) { + saveCache(cache); + console.log(`… ${saved}/${missing.length}`); + } + if (DELAY_MS > 0) { + await new Promise((r) => setTimeout(r, DELAY_MS)); + } + } + } + + const workers = Array.from({ length: Math.min(CONCURRENCY, Math.max(1, missing.length)) }, () => worker()); + await Promise.all(workers); + saveCache(cache); + + for (const f of files) { + const p = path.join(LOCALES_FR, f); + const raw = JSON.parse(fs.readFileSync(p, 'utf8')); + const out = applyTranslations(raw, cache); + fs.writeFileSync(p, `${JSON.stringify(out, null, 4)}\n`, 'utf8'); + } + console.log('Fertig: fr/*.json aktualisiert.'); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/frontend/scripts/smooth-fr-falukant.mjs b/frontend/scripts/smooth-fr-falukant.mjs new file mode 100644 index 0000000..adb837c --- /dev/null +++ b/frontend/scripts/smooth-fr-falukant.mjs @@ -0,0 +1,137 @@ +/** + * Erzeugt fr/falukant.json neu aus de/falukant.json (google-translate-api-x). + * Stellt sicher, dass benannte Platzhalter {name} exakt wie im Deutschen bleiben. + * + * Aus frontend/: node scripts/smooth-fr-falukant.mjs + */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { translate } from 'google-translate-api-x'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DE_PATH = path.join(__dirname, '../src/i18n/locales/de/falukant.json'); +const FR_PATH = path.join(__dirname, '../src/i18n/locales/fr/falukant.json'); +const CACHE_PATH = path.join(__dirname, '.falukant-fr-smooth-cache.json'); +const CONCURRENCY = Math.max(1, parseInt(process.env.I18N_MT_CONCURRENCY || '3', 10)); +const DELAY_MS = Math.max(0, parseInt(process.env.I18N_MT_DELAY_MS || '400', 10)); + +function loadCache() { + try { + return JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8')); + } catch { + return {}; + } +} + +function saveCache(cache) { + fs.writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 0), 'utf8'); +} + +function shouldSkipTranslation(str) { + if (typeof str !== 'string' || str.length === 0) return true; + if (/^[\d\s./:\\\-–—:]+$/u.test(str) && str.length < 96) return true; + if (/^([dDMYHhms\.\/:,\[\]'\s-]|yyyy|yy)+$/i.test(str.trim()) && str.length < 96) return true; + return false; +} + +/** Ersetzt {…}-Tokens in der Übersetzung durch die Namen aus dem Deutschen (gleiche Reihenfolge). */ +function alignPlaceholders(deStr, frStr) { + const deNames = [...deStr.matchAll(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g)].map((m) => m[1]); + if (deNames.length === 0) return frStr; + let i = 0; + return frStr.replace(/\{[^}]+\}/g, () => { + const name = deNames[i]; + i += 1; + return name !== undefined ? `{${name}}` : '{}'; + }); +} + +async function translateOne(text, cache) { + if (shouldSkipTranslation(text)) return text; + if (cache[text]) return cache[text]; + for (let attempt = 0; attempt < 4; attempt++) { + try { + const res = await translate(text, { from: 'de', to: 'fr' }); + const aligned = alignPlaceholders(text, res.text); + cache[text] = aligned; + return aligned; + } catch { + await new Promise((r) => setTimeout(r, 800 * (attempt + 1))); + } + } + console.warn('Übersetzung fehlgeschlagen:', text.slice(0, 72)); + cache[text] = text; + return text; +} + +function collectStrings(node, set) { + if (typeof node === 'string') { + set.add(node); + return; + } + if (Array.isArray(node)) { + for (const x of node) collectStrings(x, set); + return; + } + if (node && typeof node === 'object') { + for (const k of Object.keys(node)) collectStrings(node[k], set); + } +} + +function applyMap(node, cache) { + if (typeof node === 'string') { + if (shouldSkipTranslation(node)) return node; + return cache[node] !== undefined ? cache[node] : node; + } + if (Array.isArray(node)) return node.map((x) => applyMap(x, cache)); + if (node && typeof node === 'object') { + const out = {}; + for (const k of Object.keys(node)) { + out[k] = applyMap(node[k], cache); + } + return out; + } + return node; +} + +async function main() { + const deRoot = JSON.parse(fs.readFileSync(DE_PATH, 'utf8')); + const cache = loadCache(); + const all = new Set(); + collectStrings(deRoot, all); + const unique = [...all].filter((s) => !shouldSkipTranslation(s)); + const missing = unique.filter((s) => cache[s] === undefined); + console.log(`Falukant: ${unique.length} übersetzbare Strings, ${missing.length} ohne Cache.`); + + let next = 0; + let done = 0; + + async function worker() { + for (;;) { + const i = next++; + if (i >= missing.length) return; + const s = missing[i]; + await translateOne(s, cache); + done++; + if (done % 40 === 0) { + saveCache(cache); + console.log(`… ${done}/${missing.length}`); + } + if (DELAY_MS > 0) await new Promise((r) => setTimeout(r, DELAY_MS)); + } + } + + const workers = Array.from({ length: Math.min(CONCURRENCY, Math.max(1, missing.length)) }, () => worker()); + await Promise.all(workers); + saveCache(cache); + + const out = applyMap(deRoot, cache); + fs.writeFileSync(FR_PATH, `${JSON.stringify(out, null, 4)}\n`, 'utf8'); + console.log('Geschrieben:', FR_PATH); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index f683d48..3b881b2 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -43,6 +43,7 @@