diff --git a/apps/client/src/common/models/OntimeSettings.ts b/apps/client/src/common/models/OntimeSettings.ts
index a12319f64..2ef016c05 100644
--- a/apps/client/src/common/models/OntimeSettings.ts
+++ b/apps/client/src/common/models/OntimeSettings.ts
@@ -1,4 +1,5 @@
import { Settings } from 'ontime-types';
+import { normaliseAuxTimerNames } from 'ontime-utils';
export const ontimePlaceholderSettings: Settings = {
version: '4.0.0',
@@ -6,5 +7,5 @@ export const ontimePlaceholderSettings: Settings = {
operatorKey: null,
timeFormat: '24',
language: 'en',
- auxTimerNames: ['', '', ''],
+ auxTimerNames: normaliseAuxTimerNames(),
};
diff --git a/apps/client/src/features/app-settings/panel/settings-panel/AuxTimerSettings.tsx b/apps/client/src/features/app-settings/panel/settings-panel/AuxTimerSettings.tsx
index 0128c38ed..cff483035 100644
--- a/apps/client/src/features/app-settings/panel/settings-panel/AuxTimerSettings.tsx
+++ b/apps/client/src/features/app-settings/panel/settings-panel/AuxTimerSettings.tsx
@@ -1,4 +1,5 @@
import { Settings } from 'ontime-types';
+import { auxTimerNameMaxLength, numberOfAuxTimers } from 'ontime-utils';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
@@ -11,7 +12,8 @@ import useSettings from '../../../../common/hooks-query/useSettings';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
-const auxTimerIndexes = [1, 2, 3];
+/** zero based index of each aux timer, used to address the auxTimerNames array */
+const auxTimerIndexes = Array.from({ length: numberOfAuxTimers }, (_, index) => index);
export default function AuxTimerSettings() {
const { data, status, refetch } = useSettings();
@@ -79,8 +81,12 @@ export default function AuxTimerSettings() {
{auxTimerIndexes.map((index) => (
-
-
+
+
))}
diff --git a/apps/server/src/api-data/db/__tests__/db.parser.test.ts b/apps/server/src/api-data/db/__tests__/db.parser.test.ts
index 608445935..c2b0e8f48 100644
--- a/apps/server/src/api-data/db/__tests__/db.parser.test.ts
+++ b/apps/server/src/api-data/db/__tests__/db.parser.test.ts
@@ -90,4 +90,14 @@ describe('test parseDatabaseModel() edge cases', () => {
// @ts-expect-error -- we know this is wrong, testing imports outside domain
expect(() => parseDatabaseModel('some random dataset')).toThrow();
});
+
+ it('creates the aux timer names when importing a project file which predates the feature', () => {
+ const oldProject = structuredClone(demoDb);
+ // @ts-expect-error -- simulating a project file saved before aux timer naming existed
+ delete oldProject.settings.auxTimerNames;
+
+ const { data } = parseDatabaseModel(oldProject);
+
+ expect(data.settings.auxTimerNames).toStrictEqual(['', '', '']);
+ });
});
diff --git a/apps/server/src/api-data/db/migration/db.migration.v3.ts b/apps/server/src/api-data/db/migration/db.migration.v3.ts
index 8713a0b79..930fa0452 100644
--- a/apps/server/src/api-data/db/migration/db.migration.v3.ts
+++ b/apps/server/src/api-data/db/migration/db.migration.v3.ts
@@ -23,6 +23,7 @@ import {
customFieldLabelToKey,
eventDef as eventModel,
isKnownTimerType,
+ normaliseAuxTimerNames,
validateEndAction,
} from 'ontime-utils';
@@ -74,7 +75,15 @@ export function migrateSettings(jsonData: object): (Settings & { serverPort: num
const { serverPort, editorKey, operatorKey, timeFormat, language } = structuredClone(
jsonData.settings,
) as old_Settings;
- return { version: '4.0.0', serverPort, editorKey, operatorKey, timeFormat, language, auxTimerNames: ['', '', ''] };
+ return {
+ version: '4.0.0',
+ serverPort,
+ editorKey,
+ operatorKey,
+ timeFormat,
+ language,
+ auxTimerNames: normaliseAuxTimerNames(),
+ };
}
}
diff --git a/apps/server/src/api-data/db/migration/db.migration.v4.ts b/apps/server/src/api-data/db/migration/db.migration.v4.ts
index 90e75a948..ea7e1208a 100644
--- a/apps/server/src/api-data/db/migration/db.migration.v4.ts
+++ b/apps/server/src/api-data/db/migration/db.migration.v4.ts
@@ -1,4 +1,5 @@
import { DatabaseModel, Settings } from 'ontime-types';
+import { normaliseAuxTimerNames } from 'ontime-utils';
import { is } from '../../../utils/is.js';
@@ -23,7 +24,7 @@ export function migrateServerPort(jsonData: Partial): {
const operatorKey = settings?.operatorKey;
const timeFormat = settings?.timeFormat;
const language = settings?.language;
- const auxTimerNames = settings?.auxTimerNames ?? ['', '', ''];
+ const auxTimerNames = normaliseAuxTimerNames(settings?.auxTimerNames);
const version = '4.5.0';
db.settings = {
version,
diff --git a/apps/server/src/api-data/settings/__tests__/settings.parser.test.ts b/apps/server/src/api-data/settings/__tests__/settings.parser.test.ts
index ed5e909f7..48906eb2e 100644
--- a/apps/server/src/api-data/settings/__tests__/settings.parser.test.ts
+++ b/apps/server/src/api-data/settings/__tests__/settings.parser.test.ts
@@ -33,4 +33,21 @@ describe('parseSettings()', () => {
});
expect(result.auxTimerNames).toStrictEqual(['', '', '']);
});
+
+ it('creates the aux timer names for project files made before the feature existed', () => {
+ // a settings object as found in a project file which predates aux timer naming
+ const oldSettings = {
+ version: '4.5.0',
+ editorKey: null,
+ operatorKey: null,
+ timeFormat: '24',
+ language: 'en',
+ };
+
+ const result = parseSettings({ settings: oldSettings as Settings });
+
+ expect(result.auxTimerNames).toStrictEqual(['', '', '']);
+ // the rest of the settings are untouched
+ expect(result).toMatchObject({ timeFormat: '24', language: 'en' });
+ });
});
diff --git a/apps/server/src/api-data/settings/settings.parser.ts b/apps/server/src/api-data/settings/settings.parser.ts
index 378476138..3e19847f0 100644
--- a/apps/server/src/api-data/settings/settings.parser.ts
+++ b/apps/server/src/api-data/settings/settings.parser.ts
@@ -1,4 +1,5 @@
import { DatabaseModel, Settings } from 'ontime-types';
+import { normaliseAuxTimerNames } from 'ontime-utils';
import { getPartialProject } from '../../models/dataModel.js';
@@ -22,18 +23,7 @@ export function parseSettings(data: Partial): Settings {
operatorKey: data.settings.operatorKey ?? defaultSettings.operatorKey,
timeFormat: data.settings.timeFormat ?? defaultSettings.timeFormat,
language: data.settings.language ?? defaultSettings.language,
- auxTimerNames: sanitiseAuxTimerNames(data.settings.auxTimerNames, defaultSettings.auxTimerNames),
+ // property added in v4.6.0, older project files will not contain it
+ auxTimerNames: normaliseAuxTimerNames(data.settings.auxTimerNames),
};
}
-
-/**
- * Ensures the aux timer names are a fixed-length array of strings
- * regardless of what is found in the file
- */
-function sanitiseAuxTimerNames(maybeNames: unknown, fallback: string[]): string[] {
- const source = Array.isArray(maybeNames) ? maybeNames : [];
- return fallback.map((defaultName, index) => {
- const value = source[index];
- return typeof value === 'string' ? value : defaultName;
- });
-}
diff --git a/apps/server/src/api-data/settings/settings.validation.ts b/apps/server/src/api-data/settings/settings.validation.ts
index 031c2a473..eebac488e 100644
--- a/apps/server/src/api-data/settings/settings.validation.ts
+++ b/apps/server/src/api-data/settings/settings.validation.ts
@@ -1,4 +1,5 @@
import { body } from 'express-validator';
+import { normaliseAuxTimerNames } from 'ontime-utils';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
@@ -30,11 +31,8 @@ export const validateSettings = [
body('auxTimerNames')
.isArray()
.withMessage('auxTimerNames must be an array')
- .customSanitizer((value: unknown) => {
- // normalise to a fixed-length array of trimmed strings
- const source = Array.isArray(value) ? value : [];
- return [0, 1, 2].map((index) => (typeof source[index] === 'string' ? source[index].trim() : ''));
- }),
+ // normalise to a fixed length array of trimmed, length capped strings
+ .customSanitizer(normaliseAuxTimerNames),
requestValidationFunction,
];
diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts
index f56d646ca..395a35cc0 100644
--- a/apps/server/src/app.ts
+++ b/apps/server/src/app.ts
@@ -5,6 +5,7 @@ import cookieParser from 'cookie-parser';
import cors from 'cors';
import express from 'express';
import { LogOrigin, SimpleDirection, SimplePlayback, runtimeStorePlaceholder } from 'ontime-types';
+import { normaliseAuxTimerNames } from 'ontime-utils';
import serverTiming from 'server-timing';
import { oscServer } from './adapters/OscAdapter.js';
@@ -205,7 +206,7 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
* Module initialises the services and provides initial payload for the store
*/
const state = getState();
- const { auxTimerNames } = getDataProvider().getSettings();
+ const auxTimerNames = normaliseAuxTimerNames(getDataProvider().getSettings().auxTimerNames);
eventStore.init({
clock: state.clock,
timer: state.timer,
diff --git a/apps/server/src/models/dataModel.ts b/apps/server/src/models/dataModel.ts
index e77c557d0..5bd188db8 100644
--- a/apps/server/src/models/dataModel.ts
+++ b/apps/server/src/models/dataModel.ts
@@ -1,5 +1,5 @@
import { DatabaseModel, Rundown } from 'ontime-types';
-import { generateId } from 'ontime-utils';
+import { generateId, normaliseAuxTimerNames } from 'ontime-utils';
import { ONTIME_VERSION } from '../ONTIME_VERSION.js';
@@ -30,7 +30,7 @@ const dbModel: DatabaseModel = {
operatorKey: null,
timeFormat: '24',
language: 'en',
- auxTimerNames: ['', '', ''],
+ auxTimerNames: normaliseAuxTimerNames(),
},
viewSettings: {
overrideStyles: false,
diff --git a/apps/server/src/models/demoProject.ts b/apps/server/src/models/demoProject.ts
index 0bc1ad97c..5dd5755d6 100644
--- a/apps/server/src/models/demoProject.ts
+++ b/apps/server/src/models/demoProject.ts
@@ -1,4 +1,5 @@
import { DatabaseModel, OntimeView } from 'ontime-types';
+import { normaliseAuxTimerNames } from 'ontime-utils';
import { backstageRundown, broadcastRundown, stageRundown } from './demoRundowns.js';
@@ -29,7 +30,7 @@ export const demoDb: DatabaseModel = {
operatorKey: null,
timeFormat: '24',
language: 'en',
- auxTimerNames: ['', '', ''],
+ auxTimerNames: normaliseAuxTimerNames(),
},
viewSettings: {
dangerColor: '#ff7300',
diff --git a/apps/server/src/services/aux-timer-service/AuxTimerService.ts b/apps/server/src/services/aux-timer-service/AuxTimerService.ts
index 96f3b1930..affd60664 100644
--- a/apps/server/src/services/aux-timer-service/AuxTimerService.ts
+++ b/apps/server/src/services/aux-timer-service/AuxTimerService.ts
@@ -1,4 +1,5 @@
import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
+import { normaliseAuxTimerNames } from 'ontime-utils';
import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js';
import { timerConfig } from '../../setup/config.js';
@@ -26,14 +27,16 @@ export class AuxTimerService {
/**
* Applies custom names to the aux timers and broadcasts the change.
- * Names are indexed by aux timer position (0 -> aux1, 1 -> aux2, 2 -> aux3).
- * Used to seed the names at bootstrap and to keep them in sync with the settings.
+ * Names are given in aux timer order (index 0 is aux timer 1).
+ * Used to seed the names at bootstrap and to keep them in sync
+ * with the settings of the loaded project.
*/
- loadNames(names: string[]) {
+ loadNames(names?: string[]) {
+ const [name1, name2, name3] = normaliseAuxTimerNames(names);
const patch: AuxTimerStateUpdate = {
- auxtimer1: this.aux1.setName(names[0] ?? ''),
- auxtimer2: this.aux2.setName(names[1] ?? ''),
- auxtimer3: this.aux3.setName(names[2] ?? ''),
+ auxtimer1: this.aux1.setName(name1),
+ auxtimer2: this.aux2.setName(name2),
+ auxtimer3: this.aux3.setName(name3),
};
this.emit(patch);
}
diff --git a/apps/server/src/services/aux-timer-service/__tests__/AuxTimerService.test.ts b/apps/server/src/services/aux-timer-service/__tests__/AuxTimerService.test.ts
index e66417698..ac38396bb 100644
--- a/apps/server/src/services/aux-timer-service/__tests__/AuxTimerService.test.ts
+++ b/apps/server/src/services/aux-timer-service/__tests__/AuxTimerService.test.ts
@@ -28,6 +28,18 @@ describe('AuxTimerService', () => {
expect(patch.auxtimer3?.name).toBe('');
});
+ it('handles names missing from a project file', () => {
+ const emit = vi.fn();
+ const service = new AuxTimerService(emit, () => 0);
+
+ expect(() => service.loadNames(undefined)).not.toThrow();
+
+ const patch = emit.mock.calls.at(-1)?.[0] as Partial;
+ expect(patch.auxtimer1?.name).toBe('');
+ expect(patch.auxtimer2?.name).toBe('');
+ expect(patch.auxtimer3?.name).toBe('');
+ });
+
it('keeps the name on the timer through subsequent commands', () => {
const emit = vi.fn();
const service = new AuxTimerService(emit, () => 0);
diff --git a/apps/server/src/services/project-service/ProjectService.ts b/apps/server/src/services/project-service/ProjectService.ts
index 3cad948c3..a2c01ae98 100644
--- a/apps/server/src/services/project-service/ProjectService.ts
+++ b/apps/server/src/services/project-service/ProjectService.ts
@@ -1,9 +1,10 @@
import { copyFile } from 'fs/promises';
import { join } from 'path';
-import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types';
+import { DatabaseModel, LogOrigin, ProjectFileListResponse, RefetchKey } from 'ontime-types';
import { getErrorMessage, getFirstRundown } from 'ontime-utils';
+import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
@@ -28,6 +29,7 @@ import {
removeFileExtension,
} from '../../utils/fileManagement.js';
import { getLastLoaded, isLastLoadedProject, setLastLoaded } from '../app-state-service/AppStateService.js';
+import { auxTimerService } from '../aux-timer-service/AuxTimerService.js';
import { runtimeService } from '../runtime-service/runtime.service.js';
import {
doesProjectExist,
@@ -88,12 +90,16 @@ async function loadProject(projectData: DatabaseModel, fileName: string, rundown
// stop the runtime service
runtimeService.stop();
+ // the aux timer names belong to the project, apply the ones from the newly loaded project
+ auxTimerService.loadNames(projectData.settings.auxTimerNames);
+
// load the rundown given by key otherwise load the first in the project
const rundown =
rundownId && rundownId in projectData.rundowns
? projectData.rundowns[rundownId]
: getFirstRundown(projectData.rundowns);
+ // initialising the rundown with reload sends a refetch to the clients
await initRundown(rundown, projectData.customFields, true);
// persist the project selection
@@ -346,6 +352,12 @@ export async function patchCurrentProject(data: Partial) {
// we can pass some stuff straight to the data provider
await getDataProvider().mergeIntoData(rest);
+ // the settings may contain new aux timer names, apply them and notify the clients
+ if (rest.settings) {
+ auxTimerService.loadNames(getDataProvider().getSettings().auxTimerNames);
+ sendRefetch(RefetchKey.Settings);
+ }
+
// the rundown depends on custom fields
// so custom fields needs to be checked first
if (customFields) {
diff --git a/packages/types/src/definitions/core/Settings.type.ts b/packages/types/src/definitions/core/Settings.type.ts
index 8ffbd6cdf..2c1758756 100644
--- a/packages/types/src/definitions/core/Settings.type.ts
+++ b/packages/types/src/definitions/core/Settings.type.ts
@@ -6,6 +6,9 @@ export type Settings = {
operatorKey: null | string;
timeFormat: TimeFormat;
language: string;
- /** Custom names for the aux timers, indexed by aux timer (1, 2, 3). Empty string falls back to the default label */
+ /**
+ * Custom names for the aux timers, one entry per aux timer in order (index 0 is aux timer 1).
+ * An empty string means the timer is unnamed and consumers show the default label
+ */
auxTimerNames: string[];
};
diff --git a/packages/utils/index.ts b/packages/utils/index.ts
index 6ba939175..7f6b201b3 100644
--- a/packages/utils/index.ts
+++ b/packages/utils/index.ts
@@ -99,6 +99,13 @@ export {
export { isPlaybackActive } from './src/playback-utils/playbackstate.js';
+// aux timers
+export {
+ auxTimerNameMaxLength,
+ normaliseAuxTimerNames,
+ numberOfAuxTimers,
+} from './src/aux-timer-utils/auxTimerUtils.js';
+
//Colour
export {
colourToHex,
diff --git a/packages/utils/src/aux-timer-utils/auxTimerUtils.test.ts b/packages/utils/src/aux-timer-utils/auxTimerUtils.test.ts
new file mode 100644
index 000000000..e2e3d3947
--- /dev/null
+++ b/packages/utils/src/aux-timer-utils/auxTimerUtils.test.ts
@@ -0,0 +1,31 @@
+import { auxTimerNameMaxLength, normaliseAuxTimerNames, numberOfAuxTimers } from './auxTimerUtils.js';
+
+describe('normaliseAuxTimerNames()', () => {
+ it('generates the default value when given nothing', () => {
+ expect(normaliseAuxTimerNames()).toStrictEqual(['', '', '']);
+ });
+
+ it('always returns an entry per aux timer', () => {
+ expect(normaliseAuxTimerNames(['Speaker'])).toHaveLength(numberOfAuxTimers);
+ expect(normaliseAuxTimerNames(['a', 'b', 'c', 'extra'])).toStrictEqual(['a', 'b', 'c']);
+ });
+
+ it('pads missing entries with an empty string', () => {
+ expect(normaliseAuxTimerNames(['Speaker'])).toStrictEqual(['Speaker', '', '']);
+ });
+
+ it('trims whitespace', () => {
+ expect(normaliseAuxTimerNames([' Speaker ', '', ''])).toStrictEqual(['Speaker', '', '']);
+ });
+
+ it('caps the name length', () => {
+ const tooLong = 'a'.repeat(auxTimerNameMaxLength + 10);
+ expect(normaliseAuxTimerNames([tooLong])[0]).toHaveLength(auxTimerNameMaxLength);
+ });
+
+ it('falls back to defaults for malformed data', () => {
+ expect(normaliseAuxTimerNames('not-an-array')).toStrictEqual(['', '', '']);
+ expect(normaliseAuxTimerNames(null)).toStrictEqual(['', '', '']);
+ expect(normaliseAuxTimerNames([42, {}, undefined])).toStrictEqual(['', '', '']);
+ });
+});
diff --git a/packages/utils/src/aux-timer-utils/auxTimerUtils.ts b/packages/utils/src/aux-timer-utils/auxTimerUtils.ts
new file mode 100644
index 000000000..19260634a
--- /dev/null
+++ b/packages/utils/src/aux-timer-utils/auxTimerUtils.ts
@@ -0,0 +1,22 @@
+/** Number of aux timers available in ontime */
+export const numberOfAuxTimers = 3;
+
+/** Maximum length of a user given aux timer name */
+export const auxTimerNameMaxLength = 30;
+
+/**
+ * Normalises user or file provided aux timer names into a
+ * fixed length array of trimmed, length capped strings.
+ * Missing or malformed entries fallback to an empty string,
+ * which consumers render as the default label.
+ * Used when parsing project files, when validating API payloads
+ * and to generate the default value.
+ */
+export function normaliseAuxTimerNames(maybeNames?: unknown): string[] {
+ const source = Array.isArray(maybeNames) ? maybeNames : [];
+
+ return Array.from({ length: numberOfAuxTimers }, (_, index) => {
+ const value = source[index];
+ return typeof value === 'string' ? value.trim().slice(0, auxTimerNameMaxLength) : '';
+ });
+}
diff --git a/packages/utils/src/rundown-utils/rundownUtils.mock.ts b/packages/utils/src/rundown-utils/rundownUtils.mock.ts
index cab85e3f7..84e9b54cb 100644
--- a/packages/utils/src/rundown-utils/rundownUtils.mock.ts
+++ b/packages/utils/src/rundown-utils/rundownUtils.mock.ts
@@ -1,6 +1,8 @@
import type { DatabaseModel } from 'ontime-types';
import { EndAction, OntimeView, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
+import { normaliseAuxTimerNames } from '../aux-timer-utils/auxTimerUtils.js';
+
export const demoDb: DatabaseModel = {
rundowns: {
default: {
@@ -342,7 +344,7 @@ export const demoDb: DatabaseModel = {
operatorKey: null,
timeFormat: '24',
language: 'en',
- auxTimerNames: ['', '', ''],
+ auxTimerNames: normaliseAuxTimerNames(),
},
viewSettings: {
dangerColor: '#ff7300',