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