Source: Router/botLanguage/service.js

/**
 * 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;
};