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
+22 -1
View File
@@ -1,6 +1,9 @@
import axios from 'axios'; 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`; const assetsPath = `${apiEntryUrl}/assets`;
@@ -28,3 +31,21 @@ export async function restoreCSSContents(): Promise<string> {
const res = await axios.post(`${assetsPath}/css/restore`); const res = await axios.post(`${assetsPath}/css/restore`);
return res.data; return res.data;
} }
/**
* HTTP request to get user translation
*/
export async function getUserTranslation(): Promise<TranslationObject> {
const res = await axios.get(customTranslationsURL);
return res.data;
}
/**
* HTTP request to post user translation
*/
export async function postUserTranslation(translation: TranslationObject): Promise<void> {
await axios.post(`${assetsPath}/translations`, {
translation,
});
await ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
}
+3
View File
@@ -15,12 +15,15 @@ export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings']; export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList']; export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report']; export const REPORT = ['report'];
export const TRANSLATION = ['translation'];
// API URLs // API URLs
export const apiEntryUrl = `${serverURL}/data`; export const apiEntryUrl = `${serverURL}/data`;
const userAssetsPath = 'user'; const userAssetsPath = 'user';
const cssOverridePath = 'styles/override.css'; const cssOverridePath = 'styles/override.css';
const customTranslationsPath = 'translations/translations.json';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`; export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`; export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`;
@@ -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 };
}
+4
View File
@@ -17,6 +17,7 @@ import {
REPORT, REPORT,
RUNDOWN, RUNDOWN,
RUNTIME, RUNTIME,
TRANSLATION,
URL_PRESETS, URL_PRESETS,
VIEW_SETTINGS, VIEW_SETTINGS,
} from '../api/constants'; } from '../api/constants';
@@ -173,6 +174,9 @@ export const connectSocket = () => {
case RefetchKey.ViewSettings: case RefetchKey.ViewSettings:
ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS }); ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS });
break; break;
case RefetchKey.Translation:
ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
break;
default: { default: {
target satisfies never; target satisfies never;
break; break;
@@ -1,5 +1,6 @@
import { useEffect } from 'react'; import { lazy, useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useDisclosure } from '@mantine/hooks';
import { Settings } from 'ontime-types'; import { Settings } from 'ontime-types';
import { postSettings } from '../../../../common/api/settings'; import { postSettings } from '../../../../common/api/settings';
@@ -15,6 +16,8 @@ import * as Panel from '../../panel-utils/PanelUtils';
import GeneralPinInput from './composite/GeneralPinInput'; import GeneralPinInput from './composite/GeneralPinInput';
const TranslationModal = lazy(() => import('./composite/CustomTranslationModal'));
export default function GeneralSettings() { export default function GeneralSettings() {
const { data, status, refetch } = useSettings(); const { data, status, refetch } = useSettings();
const { const {
@@ -34,6 +37,8 @@ export default function GeneralSettings() {
}, },
}); });
const [isOpen, handler] = useDisclosure();
// update form if we get new data from server // update form if we get new data from server
useEffect(() => { useEffect(() => {
if (data) { if (data) {
@@ -63,112 +68,124 @@ export default function GeneralSettings() {
const isLoading = status === 'pending'; const isLoading = status === 'pending';
return ( return (
<Panel.Section <>
as='form' <TranslationModal isOpen={isOpen} onClose={handler.close} />
onSubmit={handleSubmit(onSubmit)} <Panel.Section
onKeyDown={(event) => preventEscape(event, onReset)} as='form'
id='app-settings' onSubmit={handleSubmit(onSubmit)}
> onKeyDown={(event) => preventEscape(event, onReset)}
<Panel.Card> id='app-settings'
<Panel.SubHeader> >
General settings <Panel.Card>
<Panel.InlineElements> <Panel.SubHeader>
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}> General settings
Revert to saved <Panel.InlineElements>
</Button> <Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
<Button type='submit' form='app-settings' loading={isSubmitting} disabled={disableSubmit} variant='primary'> Revert to saved
Save </Button>
</Button> <Button
</Panel.InlineElements> type='submit'
</Panel.SubHeader> form='app-settings'
{submitError && <Panel.Error>{submitError}</Panel.Error>} name='general-settings-submit'
<Panel.Divider /> loading={isSubmitting}
<Panel.Section> disabled={disableSubmit}
<Panel.Loader isLoading={isLoading} /> variant='primary'
<Panel.ListGroup> >
<Panel.ListItem> Save
<Panel.Field </Button>
title='Ontime server port' </Panel.InlineElements>
description={ </Panel.SubHeader>
isOntimeCloud {submitError && <Panel.Error>{submitError}</Panel.Error>}
? 'Server port disabled for Ontime Cloud' <Panel.Divider />
: 'Port ontime server listens in. Defaults to 4001 (needs app restart)' <Panel.Section>
} <Panel.Loader isLoading={isLoading} />
error={errors.serverPort?.message} <Panel.ListGroup>
/> <Panel.ListItem>
<Input <Panel.Field
id='serverPort' title='Ontime server port'
type='number' description={
maxLength={5} isOntimeCloud
style={{ width: '75px' }} ? 'Server port disabled for Ontime Cloud'
disabled={isOntimeCloud} : 'Port ontime server listens in. Defaults to 4001 (needs app restart)'
{...register('serverPort', { }
required: { value: true, message: 'Required field' }, error={errors.serverPort?.message}
max: { value: 65535, message: 'Port must be within range 1024 - 65535' }, />
min: { value: 1024, message: 'Port must be within range 1024 - 65535' }, <Input
pattern: { id='serverPort'
value: isOnlyNumbers, type='number'
message: 'Value should be numeric', maxLength={5}
}, style={{ width: '75px' }}
})} disabled={isOntimeCloud}
/> {...register('serverPort', {
</Panel.ListItem> required: { value: true, message: 'Required field' },
<Panel.ListItem> max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
<Panel.Field min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
title='Editor pin code' pattern: {
description='Protect the editor view with a pin code' value: isOnlyNumbers,
error={errors.editorKey?.message} message: 'Value should be numeric',
/> },
<GeneralPinInput register={register} formName='editorKey' disabled={disableInputs} /> })}
</Panel.ListItem> />
<Panel.ListItem> </Panel.ListItem>
<Panel.Field <Panel.ListItem>
title='Operator pin code' <Panel.Field
description='Protect the operator and cuesheet views with a pin code' title='Editor pin code'
error={errors.operatorKey?.message} description='Protect the editor view with a pin code'
/> error={errors.editorKey?.message}
<GeneralPinInput register={register} formName='operatorKey' disabled={disableInputs} /> />
</Panel.ListItem> <GeneralPinInput register={register} formName='editorKey' disabled={disableInputs} />
<Panel.ListItem> </Panel.ListItem>
<Panel.Field <Panel.ListItem>
title='Time format' <Panel.Field
description='Default time format to show in views 12 /24 hours' title='Operator pin code'
error={errors.timeFormat?.message} description='Protect the operator and cuesheet views with a pin code'
/> error={errors.operatorKey?.message}
<Select />
value={watch('timeFormat')} <GeneralPinInput register={register} formName='operatorKey' disabled={disableInputs} />
onValueChange={(value) => setValue('timeFormat', value as '12' | '24', { shouldDirty: true })} </Panel.ListItem>
defaultValue='24' <Panel.ListItem>
options={[ <Panel.Field
{ value: '12', label: '12 hours 11:00:10 PM' }, title='Time format'
{ value: '24', label: '24 hours 23:00:10' }, description='Default time format to show in views 12 /24 hours'
]} error={errors.timeFormat?.message}
/> />
</Panel.ListItem> <Select
<Panel.ListItem> value={watch('timeFormat')}
<Panel.Field onValueChange={(value) => setValue('timeFormat', value as '12' | '24', { shouldDirty: true })}
title='Views language' defaultValue='24'
description='Language to be displayed in views' options={[
error={errors.language?.message} { value: '12', label: '12 hours 11:00:10 PM' },
/> { value: '24', label: '24 hours 23:00:10' },
<Select ]}
value={watch('language')} />
onValueChange={(value) => setValue('language', value, { shouldDirty: true })} </Panel.ListItem>
disabled={disableInputs} <Panel.ListItem>
defaultValue='en' <Panel.Field
options={[ title='Views language'
{ value: 'en', label: 'English' }, description='Language to be displayed in views'
{ value: 'fr', label: 'French' }, error={errors.language?.message}
{ value: 'de', label: 'German' }, />
{ value: 'it', label: 'Italian' }, <Select
{ value: 'pt', label: 'Portuguese' }, value={watch('language')}
{ value: 'es', label: 'Spanish' }, onValueChange={(value) => setValue('language', value, { shouldDirty: true })}
]} disabled={disableInputs}
/> defaultValue='en'
</Panel.ListItem> options={[
</Panel.ListGroup> { value: 'en', label: 'English' },
</Panel.Section> { value: 'fr', label: 'French' },
</Panel.Card> { value: 'de', label: 'German' },
</Panel.Section> { value: 'it', label: 'Italian' },
{ value: 'pt', label: 'Portuguese' },
{ value: 'es', label: 'Spanish' },
{ value: 'custom', label: 'Custom' },
]}
/>
<Button onClick={handler.open}>Edit custom translation</Button>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
</Panel.Section>
</>
); );
} }
@@ -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<string, string> = {};
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<string, string>) => {
try {
const translationData: Record<string, string> = {};
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 (
<Modal
title='Edit custom translations'
isOpen={isOpen}
onClose={onClose}
showCloseButton
showBackdrop
bodyElements={
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} id='custom-translations-form'>
<Info>
Provide custom translations for the public views of Ontime. <br />
You will need to activate this in the settings by selecting &quot;Custom&quot; as the views language.
</Info>
<Panel.ListGroup>
{Object.entries(langEn).map(([key, value]) => (
<Panel.ListItem key={key}>
<Panel.Field title={value} description='' error={errors[toFormKey(key)]?.message} />
<Input
maxLength={150}
{...register(toFormKey(key), {
required: 'This field is required',
})}
placeholder={value}
/>
</Panel.ListItem>
))}
</Panel.ListGroup>
</Panel.Section>
}
footerElements={
<div>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.InlineElements align='apart'>
<Panel.InlineElements>
<Button size='large' onClick={onClose}>
Cancel
</Button>
<Button
variant='primary'
size='large'
type='submit'
form='custom-translations-form'
disabled={isSubmitting || !isDirty || !isValid}
loading={isSubmitting}
>
Save changes
</Button>
</Panel.InlineElements>
</Panel.InlineElements>
</div>
}
/>
);
}
function toFormKey(key: string) {
return key.replace('.', '_');
}
function toApiKey(key: string) {
return key.replace('_', '.');
}
@@ -1,9 +1,11 @@
import { createContext, PropsWithChildren, useCallback, useContext } from 'react'; 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 useSettings from '../common/hooks-query/useSettings';
import { langDe } from './languages/de'; import { langDe } from './languages/de';
import { langEn } from './languages/en';
import { langEs } from './languages/es'; import { langEs } from './languages/es';
import { langFr } from './languages/fr'; import { langFr } from './languages/fr';
import { langIt } from './languages/it'; import { langIt } from './languages/it';
@@ -21,15 +23,20 @@ const translationsList = {
export type TranslationKey = keyof typeof langEn; export type TranslationKey = keyof typeof langEn;
interface TranslationContextValue { interface TranslationContextValue {
userTranslation: TranslationObject;
getLocalizedString: (key: TranslationKey, lang?: string) => string; getLocalizedString: (key: TranslationKey, lang?: string) => string;
postUserTranslation: (translation: TranslationObject) => Promise<void>;
} }
const TranslationContext = createContext<TranslationContextValue>({ const TranslationContext = createContext<TranslationContextValue>({
userTranslation: langEn,
getLocalizedString: () => '', getLocalizedString: () => '',
postUserTranslation: async () => {},
}); });
export const TranslationProvider = ({ children }: PropsWithChildren) => { export const TranslationProvider = ({ children }: PropsWithChildren) => {
const { data } = useSettings(); const { data } = useSettings();
const { data: translationData } = useCustomTranslation();
const getLocalizedString = useCallback( const getLocalizedString = useCallback(
(key: TranslationKey, lang = data?.language || 'en'): string => { (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]) { if (key in translationsList[lang as keyof typeof translationsList]) {
return translationsList[lang as keyof typeof translationsList][key]; return translationsList[lang as keyof typeof translationsList][key];
} }
} else if (lang === 'custom') {
return translationData[key];
} }
return langEn[key]; return langEn[key];
}, },
[data?.language], [data?.language, translationData],
); );
const contextValue = { const contextValue = {
userTranslation: translationData,
getLocalizedString, getLocalizedString,
postUserTranslation,
}; };
return <TranslationContext.Provider value={contextValue}>{children}</TranslationContext.Provider>; return <TranslationContext.Provider value={contextValue}>{children}</TranslationContext.Provider>;
}; };
export const useTranslation = () => { export const useTranslation = () => {
const { getLocalizedString } = useContext(TranslationContext); const { userTranslation, getLocalizedString, postUserTranslation } = useContext(TranslationContext);
return { getLocalizedString }; return { userTranslation, getLocalizedString, postUserTranslation };
}; };
+1 -1
View File
@@ -1,4 +1,4 @@
import { TranslationObject } from './en'; import { TranslationObject } from 'ontime-types';
export const langDe: TranslationObject = { export const langDe: TranslationObject = {
'common.expected_finish': 'Erwartetes Ende', 'common.expected_finish': 'Erwartetes Ende',
+1 -1
View File
@@ -1,4 +1,4 @@
import { TranslationObject } from './en'; import { TranslationObject } from 'ontime-types';
export const langEs: TranslationObject = { export const langEs: TranslationObject = {
'common.expected_finish': 'Finalización esperada', 'common.expected_finish': 'Finalización esperada',
+1 -1
View File
@@ -1,4 +1,4 @@
import { TranslationObject } from './en'; import { TranslationObject } from 'ontime-types';
export const langFr: TranslationObject = { export const langFr: TranslationObject = {
'common.expected_finish': 'Fin estimée à', 'common.expected_finish': 'Fin estimée à',
+1 -1
View File
@@ -1,4 +1,4 @@
import { TranslationObject } from './en'; import { TranslationObject } from 'ontime-types';
export const langIt: TranslationObject = { export const langIt: TranslationObject = {
'common.expected_finish': 'Fine Prevista', 'common.expected_finish': 'Fine Prevista',
+1 -1
View File
@@ -1,4 +1,4 @@
import { TranslationObject } from './en'; import { TranslationObject } from 'ontime-types';
export const langPt: TranslationObject = { export const langPt: TranslationObject = {
'common.expected_finish': 'Término esperado', 'common.expected_finish': 'Término esperado',
+1 -1
View File
@@ -52,7 +52,7 @@
"dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts", "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:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts",
"dev:test": "cross-env IS_TEST=true tsx ./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": "node esbuild.electron.js",
"build:electron": "node esbuild.electron.js", "build:electron": "node esbuild.electron.js",
"build:local": "node esbuild.dev.js", "build:local": "node esbuild.dev.js",
+24
View File
@@ -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();
@@ -1,10 +1,11 @@
import express from 'express'; import express from 'express';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import type { ErrorResponse } from 'ontime-types'; import { RefetchKey, type ErrorResponse } from 'ontime-types';
import { validatePostCss } from './assets.validation.js'; import { validatePostCss, validatePostTranslation } from './assets.validation.js';
import { readCssFile, writeCssFile } from './assets.service.js'; import { readCssFile, writeCssFile, writeUserTranslation } from './assets.service.js';
import { getErrorMessage } from 'ontime-utils'; import { getErrorMessage } from 'ontime-utils';
import { defaultCss } from '../../user/styles/bundledCss.js'; import { defaultCss } from '../../user/styles/bundledCss.js';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
export const router = express.Router(); 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 }); 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 { existsSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises'; import { readFile, writeFile } from 'node:fs/promises';
import { publicFiles } from '../../setup/index.js';
import { defaultCss } from '../../user/styles/bundledCss.js'; import { defaultCss } from '../../user/styles/bundledCss.js';
/** /**
@@ -31,3 +34,13 @@ export async function writeCssFile(css: string) {
await writeFile(path, css, { encoding: 'utf8' }); 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 { body } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js'; import { requestValidationFunction } from '../validation-utils/validationFunction.js';
export const validatePostCss = [body('css').isString().trim(), requestValidationFunction]; 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 // Services
import { logger } from './classes/Logger.js'; 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 { populateStyles } from './setup/loadStyles.js';
import { eventStore } from './stores/EventStore.js'; import { eventStore } from './stores/EventStore.js';
import { runtimeService } from './services/runtime-service/runtime.service.js'; import { runtimeService } from './services/runtime-service/runtime.service.js';
import { RestorePoint, restoreService } from './services/RestoreService.js'; import { RestorePoint, restoreService } from './services/RestoreService.js';
import * as messageService from './services/message-service/message.service.js'; import * as messageService from './services/message-service/message.service.js';
import { populateDemo } from './setup/loadDemo.js';
import { getState } from './stores/runtimeState.js'; import { getState } from './stores/runtimeState.js';
import { initialiseProject } from './services/project-service/ProjectService.js'; import { initialiseProject } from './services/project-service/ProjectService.js';
import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js'; import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js';
@@ -157,6 +158,7 @@ export const initAssets = async (escalateErrorFn?: (error: string, unrecoverable
await clearUploadfolder(); await clearUploadfolder();
populateStyles(); populateStyles();
populateTranslation();
await populateDemo(); await populateDemo();
const project = await initialiseProject(); const project = await initialiseProject();
logger.info(LogOrigin.Server, `Initialised Ontime with ${project}`); logger.info(LogOrigin.Server, `Initialised Ontime with ${project}`);
+4
View File
@@ -33,4 +33,8 @@ export const config = {
}, },
uploads: 'uploads', uploads: 'uploads',
logo: 'logo', logo: 'logo',
translations: {
directory: 'translations',
filename: 'translations.json',
},
}; };
+10
View File
@@ -81,12 +81,16 @@ export const srcFiles = {
clientIndexHtml: join(srcDir.clientDir, 'index.html'), clientIndexHtml: join(srcDir.clientDir, 'index.html'),
/** Path to bundled CSS */ /** Path to bundled CSS */
cssOverride: join(srcDir.root, config.user, config.styles.directory, config.styles.filename), 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 */ /** Path to bundled external readme */
externalReadme: join(srcDir.root, config.external, 'README.md'), externalReadme: join(srcDir.root, config.external, 'README.md'),
/** Path to bundled user readme */ /** Path to bundled user readme */
userReadme: join(srcDir.root, config.user, 'README.md'), userReadme: join(srcDir.root, config.user, 'README.md'),
/** Path to bundled CSS readme */ /** Path to bundled CSS readme */
cssReadme: join(srcDir.root, config.user, config.styles.directory, 'README.md'), 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 */ /** Path to login */
login: join(srcDir.root, 'html/login.html'), login: join(srcDir.root, 'html/login.html'),
}; };
@@ -136,6 +140,8 @@ export const publicDir = {
/** path to external styles override */ /** path to external styles override */
stylesDir: join(resolvePublicDirectory, config.user, config.styles.directory), stylesDir: join(resolvePublicDirectory, config.user, config.styles.directory),
logoDir: join(resolvePublicDirectory, config.user, config.logo), logoDir: join(resolvePublicDirectory, config.user, config.logo),
/** path to translations folder */
translationsDir: join(resolvePublicDirectory, config.user, config.translations.directory),
} as const; } as const;
/** /**
@@ -148,10 +154,14 @@ export const publicFiles = {
restoreFile: join(publicDir.root, config.restoreFile), restoreFile: join(publicDir.root, config.restoreFile),
/** path to CSS override file */ /** path to CSS override file */
cssOverride: join(publicDir.stylesDir, config.styles.filename), cssOverride: join(publicDir.stylesDir, config.styles.filename),
/** path to custom translation file */
translationsFile: join(publicDir.translationsDir, config.translations.filename),
/** path to external readme file */ /** path to external readme file */
externalReadme: join(publicDir.externalDir, 'README.md'), externalReadme: join(publicDir.externalDir, 'README.md'),
/** path to user readme file */ /** path to user readme file */
userReadme: join(publicDir.userDir, 'README.md'), userReadme: join(publicDir.userDir, 'README.md'),
/** path to CSS readme file */ /** path to CSS readme file */
cssReadme: join(publicDir.stylesDir, 'README.md'), cssReadme: join(publicDir.stylesDir, 'README.md'),
/** path to translation readme file */
translationReadme: join(publicDir.translationsDir, 'README.md'),
} as const; } 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);
@@ -6,4 +6,5 @@ export enum RefetchKey {
Rundown = 'rundown', Rundown = 'rundown',
UrlPresets = 'url-presets', UrlPresets = 'url-presets',
ViewSettings = 'view-settings', ViewSettings = 'view-settings',
Translation = 'translation',
} }
+4 -5
View File
@@ -16,11 +16,7 @@ export {
type TimeField, type TimeField,
SupportedEntry as SupportedEntry, SupportedEntry as SupportedEntry,
} from './definitions/core/OntimeEntry.js'; } from './definitions/core/OntimeEntry.js';
export type { export type { RundownEntries, Rundown, ProjectRundowns } from './definitions/core/Rundown.type.js';
RundownEntries,
Rundown,
ProjectRundowns,
} from './definitions/core/Rundown.type.js';
export { TimeStrategy } from './definitions/TimeStrategy.type.js'; export { TimeStrategy } from './definitions/TimeStrategy.type.js';
export { TimerType } from './definitions/TimerType.type.js'; export { TimerType } from './definitions/TimerType.type.js';
@@ -131,3 +127,6 @@ export type { MaybeNumber, MaybeString } from './utils/utils.type.js';
// Colour // Colour
export type { RGBColour } from './definitions/Colour.type.js'; export type { RGBColour } from './definitions/Colour.type.js';
// Translations
export { langEn, type TranslationObject } from './translations/index.js';