/**
* Bot Language Service Layer
*
* Speichert und liest die Labels der Bot-UIs im Objekt-Store. Die Ablage ist
* **pro Bot-Repo** (z. B. `basic-bots`) und nicht pro Bot: mehrere Bots rendern
* dieselben geteilten UI-Komponenten, also ist ein Text, den diese Komponente
* ausgibt, genau **einmal** zu pflegen. Es gibt zwei Ebenen:
*
* botLanguages/<repo>/<lang>.json geteilt (alle Organisationen)
* botLanguages/<UIDroot>/<repo>/<lang>.json Organisation
*
* Die Datei ist die *dünne* Überschicht über dem Point of Truth des Repos:
* `src/ActionBots/shared/translations/<lang>.json` plus
* `src/ActionBots/<bot>/translations/<lang>.json`. Beides liegt im Template
* (`template.translations`, vom bot-framework bereits zusammengeführt) und wird
* hier beim Registrieren eines Bots in die geteilte Ebene vorbefüllt —
* vorhandene Werte bleiben unberührt.
*
* Die Keys sind mit dem **Komponentennamen** gepunktet (`MailTabsUI.save`),
* nicht pro Bot: dadurch finden mehrere Bots denselben Text wieder, und der
* spätere Übersetzungs-Editor zeigt sechs Komponenten statt elf Bots.
*
* Der Sprachcode ist ISO (`de`, `nl`, `fr`, …) und identisch mit dem Wert, den
* die Apps als `userLang` führen.
*
* @module BotLanguageService
*/
// @ts-check
import { myMinioClient } from '../../utils/s3Client.js';
const getBucket = () => (process.env.bucket ? process.env.bucket : 'kpe20');
const BOT_LANGUAGES_PREFIX = 'botLanguages/';
/** Fehlercodes, die "Datei gibt es nicht" bedeuten. */
const NOT_FOUND_CODES = ['NoSuchKey', 'NotFound', 'NoSuchBucket'];
/**
* Repo-Name, Sprachcode und Orga-UID landen direkt im Objekt-Key — deshalb nur
* harmlose Zeichen zulassen (kein `/`, kein `..`, keine Kontrollzeichen).
*
* @param {string} value
* @returns {boolean}
*/
const isSafeSegment = (value) =>
typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) && !value.includes('..');
/**
* @param {string} label - Name des Eingabewerts für die Fehlermeldung.
* @param {string} value
* @returns {void}
*/
const assertSegment = (label, value) => {
if (!isSafeSegment(value)) {
throw Object.assign(new Error(`Ungültiger Wert für ${label}`), { code: 'InvalidSegment' });
}
};
/**
* Leeres Ergebnis eines Leseversuchs.
* @returns {{exists: boolean, labels: Record<string, string>, meta: Object|null}}
*/
const missingFile = () => ({ exists: false, labels: {}, meta: null });
/**
* @param {string} repo
* @param {string} lang
* @returns {string}
*/
export const sharedKey = (repo, lang) => `${BOT_LANGUAGES_PREFIX}${repo}/${lang}.json`;
/**
* @param {string} UIDroot
* @param {string} repo
* @param {string} lang
* @returns {string}
*/
export const orgaKey = (UIDroot, repo, lang) => `${BOT_LANGUAGES_PREFIX}${UIDroot}/${repo}/${lang}.json`;
/**
* Objekt-Key einer Ebene. Ohne `UIDroot` die geteilte Ebene.
*
* @param {string} repo
* @param {string} lang
* @param {string|null} [UIDroot]
* @returns {string}
*/
export const botLanguageKey = (repo, lang, UIDroot = null) =>
UIDroot ? orgaKey(UIDroot, repo, lang) : sharedKey(repo, lang);
/**
* Sammelt einen Stream zu einem String.
*
* @param {import('stream').Readable} stream
* @returns {Promise<string>}
*/
const collectStream = (stream) =>
new Promise((resolve, reject) => {
/** @type {Buffer[]} */
const chunks = [];
stream.on('data', (/** @type {Buffer} */ chunk) => chunks.push(Buffer.from(chunk)));
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
stream.on('error', reject);
});
/**
* @param {any} error
* @returns {boolean}
*/
const isNotFound = (error) => NOT_FOUND_CODES.includes(error?.code) || NOT_FOUND_CODES.includes(error?.Code);
/**
* Alle Objekt-Keys unterhalb eines Präfixes.
*
* @param {string} prefix
* @returns {Promise<string[]>}
*/
const listObjectKeys = (prefix) =>
new Promise((resolve, reject) => {
/** @type {string[]} */
const keys = [];
const stream = myMinioClient.listObjectsV2(getBucket(), prefix, true);
stream.on('data', (/** @type {any} */ obj) => {
if (obj?.name) keys.push(obj.name);
});
stream.on('end', () => resolve(keys));
stream.on('error', reject);
});
/**
* Liest eine Übersetzungsdatei.
*
* Toleriert zwei Formate: das reguläre `{ _meta, labels }` und eine flache
* Map (`{ "wizard.next": "Weiter" }`) — letzteres, damit eine von Hand
* hochgeladene Datei nicht stillschweigend leer bleibt.
*
* @param {string} key - Objekt-Key im Bucket.
* @returns {Promise<{exists: boolean, labels: Record<string, string>, meta: Object|null}>}
*/
export const readLanguageFile = async (key) => {
let raw;
try {
raw = await myMinioClient.getObject(getBucket(), key).then(collectStream);
} catch (error) {
if (isNotFound(error)) return missingFile();
throw error;
}
/** @type {any} */
let parsed;
try {
parsed = JSON.parse(raw);
} catch (error) {
console.error(`[botLanguages] ${key} ist kein gültiges JSON — wird als leer behandelt`);
return { exists: true, labels: {}, meta: null };
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
console.error(`[botLanguages] ${key} ist kein Objekt — wird als leer behandelt`);
return { exists: true, labels: {}, meta: null };
}
/** @type {Record<string, string>} */
const labels = {};
const source = parsed.labels;
if (source && typeof source === 'object' && !Array.isArray(source)) {
for (const [labelKey, value] of Object.entries(source)) {
if (typeof value === 'string') labels[labelKey] = value;
}
} else {
// Flache Map ohne labels-Wrapper.
console.warn(`[botLanguages] ${key} hat kein "labels"-Objekt — flache Map wird übernommen`);
for (const [labelKey, value] of Object.entries(parsed)) {
if (labelKey === '_meta') continue;
if (typeof value === 'string') labels[labelKey] = value;
}
}
return { exists: true, labels, meta: parsed._meta ?? null };
};
/**
* Repo-Namen aus einem Key ableiten, wenn direkt unterhalb des Präfixes genau
* ein Segment steht. `botLanguages/basic-bots/de.json` → `basic-bots`,
* `botLanguages/<UID>/basic-bots/de.json` → null (das ist die Orga-Ebene).
*
* @param {string} key
* @param {string} prefix
* @param {string} suffix
* @returns {string|null}
*/
const repoFromKey = (key, prefix, suffix) => {
if (!key.startsWith(prefix) || !key.endsWith(suffix)) return null;
const rest = key.slice(prefix.length, key.length - suffix.length);
if (!rest || rest.includes('/')) return null;
return rest;
};
/**
* Dünne Überschicht **aller Bot-Repos** für eine Sprache, geteilte und
* Orga-Ebene bereits zusammengeführt (Orga gewinnt).
*
* Ein Aufruf pro App-Start; die App hält das Ergebnis im Context und öffnet
* beim Rendern eines Addons keine weitere Anfrage.
*
* @param {string} UIDroot - Organisation.
* @param {string} lang - Sprachcode (ISO).
* @returns {Promise<Record<string, Record<string, string>>>} Repo → Labels.
*/
export const listRepoLanguageSlices = async (UIDroot, lang) => {
assertSegment('lang', lang);
const suffix = `/${lang}.json`;
const orgaPrefix = `${BOT_LANGUAGES_PREFIX}${UIDroot}/`;
const [allKeys, orgaKeys] = await Promise.all([
listObjectKeys(BOT_LANGUAGES_PREFIX),
isSafeSegment(UIDroot) ? listObjectKeys(orgaPrefix) : Promise.resolve([]),
]);
const sharedRepos = new Set(
allKeys.map(key => repoFromKey(key, BOT_LANGUAGES_PREFIX, suffix)).filter(/** @type {string|null} */ repo => repo !== null)
);
const orgaRepos = new Set(
orgaKeys.map(key => repoFromKey(key, orgaPrefix, suffix)).filter(/** @type {string|null} */ repo => repo !== null)
);
const repos = [...new Set([...sharedRepos, ...orgaRepos])].sort();
/** @type {Record<string, Record<string, string>>} */
const slices = {};
await Promise.all(
repos.map(async (repo) => {
const [shared, orga] = await Promise.all([
sharedRepos.has(repo) ? readLanguageFile(sharedKey(repo, lang)) : missingFile(),
orgaRepos.has(repo) ? readLanguageFile(orgaKey(UIDroot, repo, lang)) : missingFile(),
]);
const labels = { ...shared.labels, ...orga.labels };
if (Object.keys(labels).length > 0) slices[repo] = labels;
})
);
return slices;
};
/**
* Ein Bot-Repo mit getrennten Ebenen — Grundlage für den Übersetzungs-Editor.
*
* @param {string} repo - Repo-Name (z. B. `basic-bots`).
* @param {string} lang - Sprachcode (ISO).
* @param {string|null} [UIDroot] - Organisation (für die Orga-Ebene).
* @returns {Promise<{repo: string, lang: string, UIDroot: string|null, labels: Record<string,string>, layers: {shared: Object, orga: Object}}>}
*/
export const getRepoLanguage = async (repo, lang, UIDroot = null) => {
assertSegment('repo', repo);
assertSegment('lang', lang);
const [shared, orga] = await Promise.all([
readLanguageFile(sharedKey(repo, lang)),
UIDroot ? readLanguageFile(orgaKey(UIDroot, repo, lang)) : Promise.resolve(missingFile()),
]);
return {
repo,
lang,
UIDroot: UIDroot ?? null,
labels: { ...shared.labels, ...orga.labels },
layers: { shared, orga },
};
};
/**
* Schreibt eine Übersetzungsdatei.
*
* Standard ist ein Upsert: übergebene Labels werden gesetzt, `remove` löscht
* Keys (Rücksetzen auf die darunterliegende Ebene). Mit `onlyMissing` werden
* nur Keys ergänzt, die noch nicht dastehen — so werden bestehende
* Übersetzungen nie überschrieben.
*
* @param {Object} options
* @param {string} options.repo
* @param {string} options.lang
* @param {string|null} [options.UIDroot]
* @param {Record<string, string>} [options.labels]
* @param {string[]} [options.remove]
* @param {boolean} [options.onlyMissing]
* @returns {Promise<{key: string, changed: number, written: boolean, exists: boolean, labels: Record<string,string>}>}
*/
export const putRepoLanguage = async ({
repo,
lang,
UIDroot = null,
labels = {},
remove = [],
onlyMissing = false,
}) => {
assertSegment('repo', repo);
assertSegment('lang', lang);
if (UIDroot) assertSegment('UIDroot', UIDroot);
const key = botLanguageKey(repo, lang, UIDroot);
const existing = await readLanguageFile(key);
/** @type {Record<string, string>} */
const next = { ...existing.labels };
let changed = 0;
for (const [labelKey, value] of Object.entries(labels)) {
if (typeof value !== 'string') continue;
if (onlyMissing && Object.prototype.hasOwnProperty.call(next, labelKey)) continue;
if (next[labelKey] === value) continue;
next[labelKey] = value;
changed++;
}
for (const labelKey of Array.isArray(remove) ? remove : []) {
if (Object.prototype.hasOwnProperty.call(next, labelKey)) {
delete next[labelKey];
changed++;
}
}
// Nichts zu tun (oder nichts zu schreiben) — kein leerer Schreibvorgang.
if (changed === 0 || Object.keys(next).length === 0) {
return { key, changed: 0, written: false, exists: existing.exists, labels: next };
}
const file = {
_meta: {
repo,
lang,
scope: UIDroot ? 'orga' : 'shared',
UIDroot: UIDroot ?? null,
updated: new Date().toISOString(),
},
labels: next,
};
await myMinioClient.putObject(
getBucket(),
key,
JSON.stringify(file, null, 2),
{ 'Content-Type': 'application/json' }
);
return { key, changed, written: true, exists: true, labels: next };
};
/**
* Befüllt die geteilte Ebene aus dem POT des Repos — nur fehlende Keys.
*
* Wird bei der Registrierung eines Bots aufgerufen. Der POT ist bereits die
* Mischung aus geteiltem und bot-eigenem POT (bot-framework), jeder Bot trägt
* also seinen Teil bei; `onlyMissing` macht das über mehrere Bots hinweg
* idempotent, und ein Admin-überschriebener Wert bleibt stehen.
*
* Fehler dürfen die Registrierung nicht scheitern lassen: der POT kommt beim
* nächsten Start erneut mit.
*
* @param {string} repo - Repo-Name (z. B. `basic-bots`).
* @param {Record<string, Record<string, string>>} translations - POT aus dem Template.
* @returns {Promise<Record<string, Object>>} Ergebnis je Sprache.
*/
export const seedRepoLanguages = async (repo, translations) => {
/** @type {Record<string, Object>} */
const results = {};
if (!translations || typeof translations !== 'object') return results;
for (const [lang, labels] of Object.entries(translations)) {
try {
if (!isSafeSegment(lang)) {
console.warn(`[botLanguages] Seed übersprungen: ungültiger Sprachcode "${lang}"`);
continue;
}
const result = await putRepoLanguage({ repo, lang, labels: /** @type {any} */ (labels), onlyMissing: true });
results[lang] = { key: result.key, added: result.changed, written: result.written };
if (result.written) {
console.log(`[botLanguages] ${repo} (${lang}): ${result.changed} Labels in die geteilte Ebene ergänzt`);
}
} catch (error) {
const err = /** @type {Error} */ (error);
console.error(`[botLanguages] Seed für ${repo}/${lang} fehlgeschlagen:`, err.message);
results[lang] = { error: err.message };
}
}
return results;
};