feat: add user defined translations (#1756)

* feat: add user defined translations

* add refetch key for translation (#1757)

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
This commit is contained in:
Carlos Valente
2025-09-03 16:06:03 +02:00
committed by GitHub
parent ae15f3cdc5
commit 8d2d8ef979
26 changed files with 431 additions and 129 deletions
@@ -1,10 +1,11 @@
import express from 'express';
import type { Request, Response } from 'express';
import type { ErrorResponse } from 'ontime-types';
import { validatePostCss } from './assets.validation.js';
import { readCssFile, writeCssFile } from './assets.service.js';
import { RefetchKey, type ErrorResponse } from 'ontime-types';
import { validatePostCss, validatePostTranslation } from './assets.validation.js';
import { readCssFile, writeCssFile, writeUserTranslation } from './assets.service.js';
import { getErrorMessage } from 'ontime-utils';
import { defaultCss } from '../../user/styles/bundledCss.js';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
export const router = express.Router();
@@ -38,3 +39,21 @@ router.post('/css/restore', async (_req: Request, res: Response<string | ErrorRe
res.status(500).send({ message });
}
});
router.post('/translations', validatePostTranslation, async (req: Request, res: Response<never | ErrorResponse>) => {
const { translation } = req.body;
if (!translation) {
res.status(400).send({ message: 'translation payload is required ' });
return;
}
try {
await writeUserTranslation(translation);
sendRefetch(RefetchKey.Translation);
res.status(204).send();
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
});
@@ -1,6 +1,9 @@
import { publicFiles } from '../../setup/index.js';
import type { TranslationObject } from 'ontime-types';
import { existsSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { publicFiles } from '../../setup/index.js';
import { defaultCss } from '../../user/styles/bundledCss.js';
/**
@@ -31,3 +34,13 @@ export async function writeCssFile(css: string) {
await writeFile(path, css, { encoding: 'utf8' });
}
/**
* Writes the user's custom translation file
* @param translations the updated translations to write to file
*/
export async function writeUserTranslation(translations: TranslationObject) {
const path = publicFiles.translationsFile;
const translationsString = JSON.stringify(translations, null, 2);
await writeFile(path, translationsString, { encoding: 'utf8' });
}
@@ -1,4 +1,14 @@
import { body } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
export const validatePostCss = [body('css').isString().trim(), requestValidationFunction];
export const validatePostTranslation = [
body('translation')
.custom((v) => v != null && typeof v === 'object' && !Array.isArray(v))
.withMessage('translation must be an object (key -> string)')
.bail(),
body('translation.*').isString().trim().notEmpty(),
requestValidationFunction,
];
+3 -1
View File
@@ -29,12 +29,13 @@ import { getDataProvider } from './classes/data-provider/DataProvider.js';
// Services
import { logger } from './classes/Logger.js';
import { populateDemo } from './setup/loadDemo.js';
import { populateTranslation } from './setup/loadTranslations.js';
import { populateStyles } from './setup/loadStyles.js';
import { eventStore } from './stores/EventStore.js';
import { runtimeService } from './services/runtime-service/runtime.service.js';
import { RestorePoint, restoreService } from './services/RestoreService.js';
import * as messageService from './services/message-service/message.service.js';
import { populateDemo } from './setup/loadDemo.js';
import { getState } from './stores/runtimeState.js';
import { initialiseProject } from './services/project-service/ProjectService.js';
import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js';
@@ -157,6 +158,7 @@ export const initAssets = async (escalateErrorFn?: (error: string, unrecoverable
await clearUploadfolder();
populateStyles();
populateTranslation();
await populateDemo();
const project = await initialiseProject();
logger.info(LogOrigin.Server, `Initialised Ontime with ${project}`);
+4
View File
@@ -33,4 +33,8 @@ export const config = {
},
uploads: 'uploads',
logo: 'logo',
translations: {
directory: 'translations',
filename: 'translations.json',
},
};
+10
View File
@@ -81,12 +81,16 @@ export const srcFiles = {
clientIndexHtml: join(srcDir.clientDir, 'index.html'),
/** Path to bundled CSS */
cssOverride: join(srcDir.root, config.user, config.styles.directory, config.styles.filename),
/** Path to bundled translation */
translationsFile: join(srcDir.root, config.user, config.translations.directory, config.translations.filename),
/** Path to bundled external readme */
externalReadme: join(srcDir.root, config.external, 'README.md'),
/** Path to bundled user readme */
userReadme: join(srcDir.root, config.user, 'README.md'),
/** Path to bundled CSS readme */
cssReadme: join(srcDir.root, config.user, config.styles.directory, 'README.md'),
/** Path to bundled translation readme */
translationReadme: join(srcDir.root, config.user, config.translations.directory, 'README.md'),
/** Path to login */
login: join(srcDir.root, 'html/login.html'),
};
@@ -136,6 +140,8 @@ export const publicDir = {
/** path to external styles override */
stylesDir: join(resolvePublicDirectory, config.user, config.styles.directory),
logoDir: join(resolvePublicDirectory, config.user, config.logo),
/** path to translations folder */
translationsDir: join(resolvePublicDirectory, config.user, config.translations.directory),
} as const;
/**
@@ -148,10 +154,14 @@ export const publicFiles = {
restoreFile: join(publicDir.root, config.restoreFile),
/** path to CSS override file */
cssOverride: join(publicDir.stylesDir, config.styles.filename),
/** path to custom translation file */
translationsFile: join(publicDir.translationsDir, config.translations.filename),
/** path to external readme file */
externalReadme: join(publicDir.externalDir, 'README.md'),
/** path to user readme file */
userReadme: join(publicDir.userDir, 'README.md'),
/** path to CSS readme file */
cssReadme: join(publicDir.stylesDir, 'README.md'),
/** path to translation readme file */
translationReadme: join(publicDir.translationsDir, 'README.md'),
} as const;
+23
View File
@@ -0,0 +1,23 @@
import { copyFileSync, existsSync, writeFileSync } from 'fs';
import { ensureDirectory } from '../utils/fileManagement.js';
import { defaultTranslation } from '../user/translations/bundledTranslations.js';
import { publicDir, publicFiles, srcFiles } from './index.js';
/**
* ensures directories exist and populates translation
*/
export const populateTranslation = () => {
ensureDirectory(publicDir.translationsDir);
// if translations doesn't exist we want to use startup translation
try {
copyFileSync(srcFiles.translationReadme, publicFiles.translationReadme);
if (!existsSync(publicFiles.translationsFile)) {
// copy the startup translation only if user doesnt have one
writeFileSync(publicFiles.translationsFile, defaultTranslation, { encoding: 'utf-8' });
}
} catch (_) {
/* we do not handle this */
}
};
@@ -0,0 +1,4 @@
# translations
The translations folder contain the user's custom translations which is used for custom translations in Ontime.
The file should be named `translations.json`.
@@ -0,0 +1,3 @@
import { langEn } from 'ontime-types';
export const defaultTranslation = JSON.stringify(langEn, null, 2);