From 8d2d8ef9792dd9a247a09e733747d798cb14b28f Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Wed, 3 Sep 2025 16:06:03 +0200 Subject: [PATCH] feat: add user defined translations (#1756) * feat: add user defined translations * add refetch key for translation (#1757) --------- Co-authored-by: Alex Christoffer Rasmussen --- apps/client/src/common/api/assets.ts | 23 +- apps/client/src/common/api/constants.ts | 3 + .../hooks-query/useCustomTranslation.ts | 16 ++ apps/client/src/common/utils/socket.ts | 4 + .../panel/settings-panel/GeneralSettings.tsx | 233 ++++++++++-------- .../composite/CustomTranslationModal.tsx | 118 +++++++++ .../src/translation/TranslationProvider.tsx | 19 +- apps/client/src/translation/languages/de.ts | 2 +- apps/client/src/translation/languages/es.ts | 2 +- apps/client/src/translation/languages/fr.ts | 2 +- apps/client/src/translation/languages/it.ts | 2 +- apps/client/src/translation/languages/pt.ts | 2 +- apps/server/package.json | 2 +- apps/server/scripts/bundleTranslation.ts | 24 ++ .../src/api-data/assets/assets.router.ts | 25 +- .../src/api-data/assets/assets.service.ts | 15 +- .../src/api-data/assets/assets.validation.ts | 10 + apps/server/src/app.ts | 4 +- apps/server/src/setup/config.ts | 4 + apps/server/src/setup/index.ts | 10 + apps/server/src/setup/loadTranslations.ts | 23 ++ apps/server/src/user/translations/README.md | 4 + .../user/translations/bundledTranslations.ts | 3 + .../types/src/api/websocket/refetch.type.ts | 1 + packages/types/src/index.ts | 9 +- .../types/src/translations/index.ts | 0 26 files changed, 431 insertions(+), 129 deletions(-) create mode 100644 apps/client/src/common/hooks-query/useCustomTranslation.ts create mode 100644 apps/client/src/features/app-settings/panel/settings-panel/composite/CustomTranslationModal.tsx create mode 100644 apps/server/scripts/bundleTranslation.ts create mode 100644 apps/server/src/setup/loadTranslations.ts create mode 100644 apps/server/src/user/translations/README.md create mode 100644 apps/server/src/user/translations/bundledTranslations.ts rename apps/client/src/translation/languages/en.ts => packages/types/src/translations/index.ts (100%) diff --git a/apps/client/src/common/api/assets.ts b/apps/client/src/common/api/assets.ts index 32203e66c..65be05f78 100644 --- a/apps/client/src/common/api/assets.ts +++ b/apps/client/src/common/api/assets.ts @@ -1,6 +1,9 @@ import axios from 'axios'; +import { TranslationObject } from 'ontime-types'; -import { apiEntryUrl } from './constants'; +import { ontimeQueryClient } from '../../common/queryClient'; + +import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants'; const assetsPath = `${apiEntryUrl}/assets`; @@ -28,3 +31,21 @@ export async function restoreCSSContents(): Promise { const res = await axios.post(`${assetsPath}/css/restore`); return res.data; } + +/** + * HTTP request to get user translation + */ +export async function getUserTranslation(): Promise { + const res = await axios.get(customTranslationsURL); + return res.data; +} + +/** + * HTTP request to post user translation + */ +export async function postUserTranslation(translation: TranslationObject): Promise { + await axios.post(`${assetsPath}/translations`, { + translation, + }); + await ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION }); +} diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts index a08118089..aad81975f 100644 --- a/apps/client/src/common/api/constants.ts +++ b/apps/client/src/common/api/constants.ts @@ -15,12 +15,15 @@ export const URL_PRESETS = ['urlpresets']; export const VIEW_SETTINGS = ['viewSettings']; export const CLIENT_LIST = ['clientList']; export const REPORT = ['report']; +export const TRANSLATION = ['translation']; // API URLs export const apiEntryUrl = `${serverURL}/data`; const userAssetsPath = 'user'; const cssOverridePath = 'styles/override.css'; +const customTranslationsPath = 'translations/translations.json'; export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`; export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`; +export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`; diff --git a/apps/client/src/common/hooks-query/useCustomTranslation.ts b/apps/client/src/common/hooks-query/useCustomTranslation.ts new file mode 100644 index 000000000..e04acb612 --- /dev/null +++ b/apps/client/src/common/hooks-query/useCustomTranslation.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; +import { langEn } from 'ontime-types'; + +import { getUserTranslation } from '../../common/api/assets'; +import { TRANSLATION } from '../../common/api/constants'; +import { queryRefetchIntervalSlow } from '../../ontimeConfig'; + +export function useCustomTranslation() { + const { data, status, refetch } = useQuery({ + queryKey: TRANSLATION, + queryFn: getUserTranslation, + placeholderData: (previousData, _previousQuery) => previousData, + refetchInterval: queryRefetchIntervalSlow, + }); + return { data: data ?? langEn, status, refetch }; +} diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts index ce925b1d9..9c05be079 100644 --- a/apps/client/src/common/utils/socket.ts +++ b/apps/client/src/common/utils/socket.ts @@ -17,6 +17,7 @@ import { REPORT, RUNDOWN, RUNTIME, + TRANSLATION, URL_PRESETS, VIEW_SETTINGS, } from '../api/constants'; @@ -173,6 +174,9 @@ export const connectSocket = () => { case RefetchKey.ViewSettings: ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS }); break; + case RefetchKey.Translation: + ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION }); + break; default: { target satisfies never; break; diff --git a/apps/client/src/features/app-settings/panel/settings-panel/GeneralSettings.tsx b/apps/client/src/features/app-settings/panel/settings-panel/GeneralSettings.tsx index 7ea3b780f..325000257 100644 --- a/apps/client/src/features/app-settings/panel/settings-panel/GeneralSettings.tsx +++ b/apps/client/src/features/app-settings/panel/settings-panel/GeneralSettings.tsx @@ -1,5 +1,6 @@ -import { useEffect } from 'react'; +import { lazy, useEffect } from 'react'; import { useForm } from 'react-hook-form'; +import { useDisclosure } from '@mantine/hooks'; import { Settings } from 'ontime-types'; import { postSettings } from '../../../../common/api/settings'; @@ -15,6 +16,8 @@ import * as Panel from '../../panel-utils/PanelUtils'; import GeneralPinInput from './composite/GeneralPinInput'; +const TranslationModal = lazy(() => import('./composite/CustomTranslationModal')); + export default function GeneralSettings() { const { data, status, refetch } = useSettings(); const { @@ -34,6 +37,8 @@ export default function GeneralSettings() { }, }); + const [isOpen, handler] = useDisclosure(); + // update form if we get new data from server useEffect(() => { if (data) { @@ -63,112 +68,124 @@ export default function GeneralSettings() { const isLoading = status === 'pending'; return ( - preventEscape(event, onReset)} - id='app-settings' - > - - - General settings - - - - - - {submitError && {submitError}} - - - - - - - - - - - - - - - - - - - setValue('language', value, { shouldDirty: true })} - disabled={disableInputs} - defaultValue='en' - options={[ - { value: 'en', label: 'English' }, - { value: 'fr', label: 'French' }, - { value: 'de', label: 'German' }, - { value: 'it', label: 'Italian' }, - { value: 'pt', label: 'Portuguese' }, - { value: 'es', label: 'Spanish' }, - ]} - /> - - - - - + <> + + preventEscape(event, onReset)} + id='app-settings' + > + + + General settings + + + + + + {submitError && {submitError}} + + + + + + + + + + + + + + + + + + + setValue('language', value, { shouldDirty: true })} + disabled={disableInputs} + defaultValue='en' + options={[ + { value: 'en', label: 'English' }, + { value: 'fr', label: 'French' }, + { value: 'de', label: 'German' }, + { value: 'it', label: 'Italian' }, + { value: 'pt', label: 'Portuguese' }, + { value: 'es', label: 'Spanish' }, + { value: 'custom', label: 'Custom' }, + ]} + /> + + + + + + + ); } diff --git a/apps/client/src/features/app-settings/panel/settings-panel/composite/CustomTranslationModal.tsx b/apps/client/src/features/app-settings/panel/settings-panel/composite/CustomTranslationModal.tsx new file mode 100644 index 000000000..9808ec9a2 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/settings-panel/composite/CustomTranslationModal.tsx @@ -0,0 +1,118 @@ +import { useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { langEn, TranslationObject } from 'ontime-types'; + +import { maybeAxiosError } from '../../../../../common/api/utils'; +import Button from '../../../../../common/components/buttons/Button'; +import Info from '../../../../../common/components/info/Info'; +import Input from '../../../../../common/components/input/input/Input'; +import Modal from '../../../../../common/components/modal/Modal'; +import { useTranslation } from '../../../../../translation/TranslationProvider'; +import * as Panel from '../../../panel-utils/PanelUtils'; + +interface CustomTranslationModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function CustomTranslationModal({ isOpen, onClose }: CustomTranslationModalProps) { + const { userTranslation, postUserTranslation } = useTranslation(); + + const defaultValues = useMemo(() => { + const values: Record = {}; + Object.keys(langEn).forEach((key) => { + values[toFormKey(key)] = userTranslation[key as keyof TranslationObject] || ''; + }); + return values; + }, [userTranslation]); + + const { + handleSubmit, + register, + reset, + formState: { isSubmitting, isDirty, errors, isValid }, + setError, + } = useForm({ + defaultValues, + resetOptions: { + keepDirtyValues: true, + }, + mode: 'onChange', + }); + + const onSubmit = async (formData: Record) => { + try { + const translationData: Record = {}; + Object.keys(formData).forEach((key) => { + translationData[toApiKey(key)] = formData[key]; + }); + + await postUserTranslation(translationData as TranslationObject); + reset(formData); + } catch (error) { + setError('root', { message: maybeAxiosError(error) }); + } + }; + + return ( + + + Provide custom translations for the public views of Ontime.
+ You will need to activate this in the settings by selecting "Custom" as the views language. +
+ + {Object.entries(langEn).map(([key, value]) => ( + + + + + ))} + + + } + footerElements={ +
+ {errors?.root && {errors.root.message}} + + + + + + +
+ } + /> + ); +} + +function toFormKey(key: string) { + return key.replace('.', '_'); +} + +function toApiKey(key: string) { + return key.replace('_', '.'); +} diff --git a/apps/client/src/translation/TranslationProvider.tsx b/apps/client/src/translation/TranslationProvider.tsx index 2ceefe509..96c43d9ca 100644 --- a/apps/client/src/translation/TranslationProvider.tsx +++ b/apps/client/src/translation/TranslationProvider.tsx @@ -1,9 +1,11 @@ import { createContext, PropsWithChildren, useCallback, useContext } from 'react'; +import { langEn, TranslationObject } from 'ontime-types'; +import { postUserTranslation } from '../common/api/assets'; +import { useCustomTranslation } from '../common/hooks-query/useCustomTranslation'; import useSettings from '../common/hooks-query/useSettings'; import { langDe } from './languages/de'; -import { langEn } from './languages/en'; import { langEs } from './languages/es'; import { langFr } from './languages/fr'; import { langIt } from './languages/it'; @@ -21,15 +23,20 @@ const translationsList = { export type TranslationKey = keyof typeof langEn; interface TranslationContextValue { + userTranslation: TranslationObject; getLocalizedString: (key: TranslationKey, lang?: string) => string; + postUserTranslation: (translation: TranslationObject) => Promise; } const TranslationContext = createContext({ + userTranslation: langEn, getLocalizedString: () => '', + postUserTranslation: async () => {}, }); export const TranslationProvider = ({ children }: PropsWithChildren) => { const { data } = useSettings(); + const { data: translationData } = useCustomTranslation(); const getLocalizedString = useCallback( (key: TranslationKey, lang = data?.language || 'en'): string => { @@ -37,20 +44,24 @@ export const TranslationProvider = ({ children }: PropsWithChildren) => { if (key in translationsList[lang as keyof typeof translationsList]) { return translationsList[lang as keyof typeof translationsList][key]; } + } else if (lang === 'custom') { + return translationData[key]; } return langEn[key]; }, - [data?.language], + [data?.language, translationData], ); const contextValue = { + userTranslation: translationData, getLocalizedString, + postUserTranslation, }; return {children}; }; export const useTranslation = () => { - const { getLocalizedString } = useContext(TranslationContext); - return { getLocalizedString }; + const { userTranslation, getLocalizedString, postUserTranslation } = useContext(TranslationContext); + return { userTranslation, getLocalizedString, postUserTranslation }; }; diff --git a/apps/client/src/translation/languages/de.ts b/apps/client/src/translation/languages/de.ts index 6e9236d5f..e23980f45 100644 --- a/apps/client/src/translation/languages/de.ts +++ b/apps/client/src/translation/languages/de.ts @@ -1,4 +1,4 @@ -import { TranslationObject } from './en'; +import { TranslationObject } from 'ontime-types'; export const langDe: TranslationObject = { 'common.expected_finish': 'Erwartetes Ende', diff --git a/apps/client/src/translation/languages/es.ts b/apps/client/src/translation/languages/es.ts index 37a82621d..dc6e9df7b 100644 --- a/apps/client/src/translation/languages/es.ts +++ b/apps/client/src/translation/languages/es.ts @@ -1,4 +1,4 @@ -import { TranslationObject } from './en'; +import { TranslationObject } from 'ontime-types'; export const langEs: TranslationObject = { 'common.expected_finish': 'Finalización esperada', diff --git a/apps/client/src/translation/languages/fr.ts b/apps/client/src/translation/languages/fr.ts index 9f8437ca0..6c8a05bfb 100644 --- a/apps/client/src/translation/languages/fr.ts +++ b/apps/client/src/translation/languages/fr.ts @@ -1,4 +1,4 @@ -import { TranslationObject } from './en'; +import { TranslationObject } from 'ontime-types'; export const langFr: TranslationObject = { 'common.expected_finish': 'Fin estimée à', diff --git a/apps/client/src/translation/languages/it.ts b/apps/client/src/translation/languages/it.ts index c2d14e018..c5a28f6f1 100644 --- a/apps/client/src/translation/languages/it.ts +++ b/apps/client/src/translation/languages/it.ts @@ -1,4 +1,4 @@ -import { TranslationObject } from './en'; +import { TranslationObject } from 'ontime-types'; export const langIt: TranslationObject = { 'common.expected_finish': 'Fine Prevista', diff --git a/apps/client/src/translation/languages/pt.ts b/apps/client/src/translation/languages/pt.ts index b2b6373e0..ad4924045 100644 --- a/apps/client/src/translation/languages/pt.ts +++ b/apps/client/src/translation/languages/pt.ts @@ -1,4 +1,4 @@ -import { TranslationObject } from './en'; +import { TranslationObject } from 'ontime-types'; export const langPt: TranslationObject = { 'common.expected_finish': 'Término esperado', diff --git a/apps/server/package.json b/apps/server/package.json index af0b87f75..5995ed691 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -52,7 +52,7 @@ "dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts", "dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts", "dev:test": "cross-env IS_TEST=true tsx ./src/index.ts", - "prebuild": "tsx ./scripts/bundleCss.ts", + "prebuild": "tsx ./scripts/bundleCss.ts && tsx ./scripts/bundleTranslation.ts", "build": "node esbuild.electron.js", "build:electron": "node esbuild.electron.js", "build:local": "node esbuild.dev.js", diff --git a/apps/server/scripts/bundleTranslation.ts b/apps/server/scripts/bundleTranslation.ts new file mode 100644 index 000000000..d30422379 --- /dev/null +++ b/apps/server/scripts/bundleTranslation.ts @@ -0,0 +1,24 @@ +import { existsSync } from 'fs'; +import { writeFile } from 'node:fs/promises'; +import { defaultTranslation } from '../src/user/translations/bundledTranslation'; +import path from 'path'; + +/** + * Script to write contents of default translation to translation.json + */ +async function bundleTranslation() { + try { + const translationDir = path.resolve(process.cwd(), 'src', 'user', 'translations'); + const translationsFile = path.resolve(translationDir, 'translations.json'); + + if (!existsSync(translationsFile)) { + throw new Error('File does not exist'); + } + + await writeFile(translationsFile, defaultTranslation, { encoding: 'utf8' }); + } catch (error) { + console.error('Failed writing to translations file: ', error); + } +} + +bundleTranslation(); diff --git a/apps/server/src/api-data/assets/assets.router.ts b/apps/server/src/api-data/assets/assets.router.ts index 606e4a9a7..d74e5fca3 100644 --- a/apps/server/src/api-data/assets/assets.router.ts +++ b/apps/server/src/api-data/assets/assets.router.ts @@ -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) => { + 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 }); + } +}); diff --git a/apps/server/src/api-data/assets/assets.service.ts b/apps/server/src/api-data/assets/assets.service.ts index 2e5cee6c8..4e208a2ca 100644 --- a/apps/server/src/api-data/assets/assets.service.ts +++ b/apps/server/src/api-data/assets/assets.service.ts @@ -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' }); +} diff --git a/apps/server/src/api-data/assets/assets.validation.ts b/apps/server/src/api-data/assets/assets.validation.ts index 10d134b38..2d5b34254 100644 --- a/apps/server/src/api-data/assets/assets.validation.ts +++ b/apps/server/src/api-data/assets/assets.validation.ts @@ -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, +]; diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index ef145a051..f47daf928 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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}`); diff --git a/apps/server/src/setup/config.ts b/apps/server/src/setup/config.ts index 6d845f097..23fa2e9af 100644 --- a/apps/server/src/setup/config.ts +++ b/apps/server/src/setup/config.ts @@ -33,4 +33,8 @@ export const config = { }, uploads: 'uploads', logo: 'logo', + translations: { + directory: 'translations', + filename: 'translations.json', + }, }; diff --git a/apps/server/src/setup/index.ts b/apps/server/src/setup/index.ts index 0b14de7b0..dc0cb615c 100644 --- a/apps/server/src/setup/index.ts +++ b/apps/server/src/setup/index.ts @@ -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; diff --git a/apps/server/src/setup/loadTranslations.ts b/apps/server/src/setup/loadTranslations.ts new file mode 100644 index 000000000..ff6c78f0b --- /dev/null +++ b/apps/server/src/setup/loadTranslations.ts @@ -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 */ + } +}; diff --git a/apps/server/src/user/translations/README.md b/apps/server/src/user/translations/README.md new file mode 100644 index 000000000..c566c3e98 --- /dev/null +++ b/apps/server/src/user/translations/README.md @@ -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`. diff --git a/apps/server/src/user/translations/bundledTranslations.ts b/apps/server/src/user/translations/bundledTranslations.ts new file mode 100644 index 000000000..be3f34b65 --- /dev/null +++ b/apps/server/src/user/translations/bundledTranslations.ts @@ -0,0 +1,3 @@ +import { langEn } from 'ontime-types'; + +export const defaultTranslation = JSON.stringify(langEn, null, 2); diff --git a/packages/types/src/api/websocket/refetch.type.ts b/packages/types/src/api/websocket/refetch.type.ts index bc3759d14..e891ffcd2 100644 --- a/packages/types/src/api/websocket/refetch.type.ts +++ b/packages/types/src/api/websocket/refetch.type.ts @@ -6,4 +6,5 @@ export enum RefetchKey { Rundown = 'rundown', UrlPresets = 'url-presets', ViewSettings = 'view-settings', + Translation = 'translation', } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index e993da06e..6d9400e59 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -16,11 +16,7 @@ export { type TimeField, SupportedEntry as SupportedEntry, } from './definitions/core/OntimeEntry.js'; -export type { - RundownEntries, - Rundown, - ProjectRundowns, -} from './definitions/core/Rundown.type.js'; +export type { RundownEntries, Rundown, ProjectRundowns } from './definitions/core/Rundown.type.js'; export { TimeStrategy } from './definitions/TimeStrategy.type.js'; export { TimerType } from './definitions/TimerType.type.js'; @@ -131,3 +127,6 @@ export type { MaybeNumber, MaybeString } from './utils/utils.type.js'; // Colour export type { RGBColour } from './definitions/Colour.type.js'; + +// Translations +export { langEn, type TranslationObject } from './translations/index.js'; diff --git a/apps/client/src/translation/languages/en.ts b/packages/types/src/translations/index.ts similarity index 100% rename from apps/client/src/translation/languages/en.ts rename to packages/types/src/translations/index.ts