Source: Router/languageFile/service.js

/**
 * Language File Service Layer
 *
 * Handles business logic for language file storage, retrieval and deletion
 * via the configured S3/MinIO bucket.
 *
 * @module LanguageFileService
 */

// @ts-check

import { myMinioClient } from '../../utils/s3Client.js';

const bucket = process.env.bucket ? process.env.bucket : 'kpe20';

/**
 * Generate the storage key for an uploaded language file.
 *
 * `params.UIDroot` selects the organization layer; without it the file goes to
 * the shared layer.
 *
 * @param {{ fields: any, filename: { filename: string }, extension: string, params: { api: string, UIDroot?: string } }} opts
 * @returns {string}
 */
export const languageUrlGen = ({ fields, filename, extension, params }) => {
    return languageKey(params.api, filename.filename, params.UIDroot ?? null);
};

/**
 * MIME-type filter that accepts only JSON files.
 *
 * @param {string} mimeType
 * @param {string} extension
 * @returns {boolean}
 */
export const filterJSON = (mimeType, extension) => {
    return ['application/json'].includes(mimeType) && ['json'].includes(extension);
};

/**
 * Build the storage key of a language file.
 *
 * Two layers live in the bucket:
 *
 *     <app>/languages/<lang>.json              shared (all organizations)
 *     <app>/languages/<UIDroot>/<lang>.json    organization
 *
 * The shared file usually holds the translations; an organization file only
 * carries its own terminology and wins over the shared one.
 *
 * @param {string} app            - Application identifier (bucket sub-path).
 * @param {string} filename       - File name inside the languages directory.
 * @param {string|null} [UIDroot] - Organization; `null` for the shared layer.
 * @returns {string}
 */
export const languageKey = (app, filename, UIDroot = null) =>
    UIDroot ? `${app}/languages/${UIDroot}/${filename}` : `${app}/languages/${filename}`;

/**
 * Build the scoped storage key from a raw key and a UID prefix.
 *
 * @param {string} key  - The raw object key returned by S3/MinIO.
 * @param {string} UID  - The owner UID used as a path prefix.
 * @returns {string}
 */
export const keyComponents = (key, UID) => {
    return `${UID}/${encodeURIComponent(key.replace(`${UID}/`, ''))}`;
};

/**
 * Retrieve a language file stream from the object store.
 *
 * @param {string} app            - Application identifier (bucket sub-path).
 * @param {string} filename       - File name inside the languages directory.
 * @param {string|null} [UIDroot] - Organization; `null` for the shared layer.
 * @returns {Promise<import('stream').Readable>} Readable stream of the object.
 * @throws {Error} When the object cannot be found or accessed.
 */
export const getLanguageFile = (app, filename, UIDroot = null) => {
    return new Promise((resolve, reject) => {
        myMinioClient.getObject(
            bucket,
            languageKey(app, filename, UIDroot),
            /** @param {any} err @param {any} data */
            (err, data) => {
                if (err) {
                    reject(err);
                } else {
                    resolve(data);
                }
            }
        );
    });
};

/**
 * Read the object metadata (ETag, last modification) of a language file.
 *
 * Used for cache validation: language files change rarely but are requested on
 * every app start, so a cheap HEAD lets us answer with 304 in the common case.
 * A missing object is not an error here — the caller decides.
 *
 * @param {string} app            - Application identifier (bucket sub-path).
 * @param {string} filename       - File name inside the languages directory.
 * @param {string|null} [UIDroot] - Organization; `null` for the shared layer.
 * @returns {Promise<{etag: string, lastModified: Date}|null>} Metadata or `null`.
 */
export const statLanguageFile = async (app, filename, UIDroot = null) => {
    try {
        const stat = await myMinioClient.statObject(bucket, languageKey(app, filename, UIDroot));
        return { etag: stat.etag, lastModified: stat.lastModified };
    } catch (e) {
        const err = /** @type {any} */ (e);
        if (err?.code === 'NoSuchKey' || err?.code === 'NotFound') return null;
        throw e;
    }
};

/**
 * List all language files across all applications.
 *
 * @returns {Promise<Array<{name: string, size: number, lastModified: Date}>>}
 */
export const listLanguageFiles = () => {
    return new Promise((resolve, reject) => {
        /** @type {{name: string, size: number, lastModified: Date}[]} */
        const objects = [];
        const stream = myMinioClient.extensions.listObjectsV2WithMetadata(bucket, '', true);

        stream.on('data', /** @param {any} obj */ (obj) => {
            if (obj.name && obj.name.includes('/languages/')) {
                objects.push({ name: obj.name, size: obj.size, lastModified: obj.lastModified });
            }
        });

        stream.on('end', () => resolve(objects));
        stream.on('error', /** @param {any} err */ (err) => reject(err));
    });
};

/**
 * List the language files of an application.
 *
 * Only the shared layer is listed: the recursive walk also sees the
 * organization sub-folders, which would otherwise show up as language
 * "names" in the settings picker.
 *
 * @param {string} app - Application identifier (bucket sub-path prefix).
 * @returns {Promise<Array<{name: string, size: number, lastModified: Date}>>}
 */
export const listLanguageFilesByApp = (app) => {
    return new Promise((resolve, reject) => {
        /** @type {{name: string, size: number, lastModified: Date}[]} */
        const objects = [];
        const stream = myMinioClient.extensions.listObjectsV2WithMetadata(bucket, `${app}/languages/`, true);

        stream.on('data', /** @param {any} obj */ (obj) => {
            if (!obj.name) return;
            // Nur Dateien direkt unter `<app>/languages/` — keine Orga-Unterordner.
            const rest = obj.name.slice(`${app}/languages/`.length);
            if (rest && !rest.includes('/')) {
                objects.push({ name: obj.name, size: obj.size, lastModified: obj.lastModified });
            }
        });

        stream.on('end', () => resolve(objects));
        stream.on('error', /** @param {any} err */ (err) => reject(err));
    });
};

/**
 * Delete a language file from the object store.
 *
 * @param {string} app            - Application identifier (bucket sub-path).
 * @param {string} filename       - File name inside the languages directory.
 * @param {string|null} [UIDroot] - Organization; `null` for the shared layer.
 * @returns {Promise<void>}
 */
export const deleteLanguageFile = async (app, filename, UIDroot = null) => {
    return myMinioClient.removeObject(bucket, languageKey(app, filename, UIDroot));
};

export { bucket };