Files
ontime/apps/server/src/api-data/db/migration/db.migration.v3.ts
T
Claude 59bff62f76 refactor: address review feedback on aux timer naming
- Revert the automation action labels back to "Aux N: action" (no
  functional reason to change this wording).
- Simplify the aux timers header in the playback control: the flex
  container now only handles layout, and the label text reuses the
  same font-size/color as the per-timer labels below it, instead of a
  bespoke style block duplicating those values.
- Replace the generic, length-driven normaliser with an explicit
  sanitiseAuxTimerNames() that always deals with exactly three timers,
  and rename it away from "normalise" (which didn't convey that it
  trims, caps length and fills in missing entries). Static defaults
  now use a plain ['', '', ''] literal instead of calling the
  sanitiser with no input to sanitise.
- Settings.type.ts and AuxTimerSettings.tsx no longer generate their
  three fields from a loop; the form mirrors the same explicit,
  one-field-per-row style already used by GeneralSettings.tsx.
- Trim comments that only restated what the following line already
  says, keeping the ones that explain non-obvious behaviour (why
  AuxTimerService needs to be resynced separately from the data
  provider, why SimpleTimer.reset() preserves the name).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCZejVTzuAY3tHTE6nB3JH
2026-08-01 07:15:12 +00:00

444 lines
13 KiB
TypeScript

import {
AutomationSettings,
CustomFields,
DatabaseModel,
EndAction,
EntryCustomFields,
NormalisedAutomation,
OntimeEntry,
OntimeGroup,
ProjectData,
ProjectRundowns,
Rundown,
Settings,
SupportedEntry,
TimerLifeCycle,
Trigger,
URLPreset,
ViewSettings,
Day,
} from 'ontime-types';
import {
checkRegex,
customFieldLabelToKey,
eventDef as eventModel,
isKnownTimerType,
validateEndAction,
} from 'ontime-utils';
import { getPartialProject } from '../../../models/dataModel.js';
import { is } from '../../../utils/is.js';
// the methodology of the migrations is to just change the necessary keys to match with v4
// and then let the normal project parser handle ensuring the the file is correct
// we should also avoid relying on the types package as this file should continue to work with old types when things change
export function shouldUseThisMigration(jsonData: object): boolean {
return (
is.objectWithKeys(jsonData, ['settings']) &&
is.object(jsonData.settings) &&
is.objectWithKeys(jsonData.settings, ['version']) &&
typeof jsonData.settings.version === 'string' &&
jsonData.settings.version.split('.')[0] === '3'
);
}
export function migrateAllData(jsonData: object): Partial<DatabaseModel> {
const settings = migrateSettings(jsonData);
const viewSettings = migrateViewSettings(jsonData);
const urlPresets = migrateURLPresets(jsonData);
const project = migrateProjectData(jsonData);
const migratedCustom = migrateCustomFields(jsonData);
const customFields = migratedCustom?.customFields;
const rundowns = migrateRundown(jsonData, migratedCustom?.translationTable);
const automation = migrateAutomations(jsonData);
return { settings, viewSettings, urlPresets, project, customFields, rundowns, automation };
}
type old_Settings = {
version: string;
serverPort: number;
editorKey: null | string;
operatorKey: null | string;
timeFormat: '12' | '24';
language: string;
};
/**
* migrates a settings from v3 to v4
* - update the version number
*/
export function migrateSettings(jsonData: object): (Settings & { serverPort: number }) | undefined {
if (is.objectWithKeys(jsonData, ['settings']) && is.object(jsonData.settings)) {
const { serverPort, editorKey, operatorKey, timeFormat, language } = structuredClone(
jsonData.settings,
) as old_Settings;
return {
version: '4.0.0',
serverPort,
editorKey,
operatorKey,
timeFormat,
language,
auxTimerNames: ['', '', ''],
};
}
}
type old_ViewSettings = {
dangerColor: string;
normalColor: string;
overrideStyles: boolean;
warningColor: string;
freezeEnd: boolean;
endMessage: string;
};
/**
* migrates a view settings from v3 to v4
* - drop `freezeEnd`
* - drop `endMessage`
*/
export function migrateViewSettings(jsonData: object): ViewSettings | undefined {
if (is.objectWithKeys(jsonData, ['viewSettings']) && is.object(jsonData.viewSettings)) {
const { dangerColor, normalColor, overrideStyles, warningColor } = structuredClone(
jsonData.viewSettings,
) as old_ViewSettings;
return { dangerColor, normalColor, overrideStyles, warningColor };
}
}
type old_URLPreset = {
enabled: boolean;
alias: string;
pathAndParams: string;
}[];
/**
* migrates a url presets from v3 to v4
* - pathAndParams split into a target and search
*/
export function migrateURLPresets(jsonData: object): URLPreset[] | undefined {
if (is.objectWithKeys(jsonData, ['urlPresets']) && is.array(jsonData.urlPresets)) {
const oldURLPresets = structuredClone(jsonData.urlPresets) as old_URLPreset;
const newURLPreset: URLPreset[] = oldURLPresets.map(({ enabled, alias, pathAndParams }) => {
const [target, search] = pathAndParams.split('?');
return { enabled, alias, target, search, displayInNav: false, options: {} } as URLPreset;
});
return newURLPreset;
}
}
type old_ProjectData = {
title: string;
description: string;
backstageUrl: string;
backstageInfo: string;
publicUrl: string;
publicInfo: string;
logo?: string; // is not present in old files
custom?: { title: string; value: string; url: string }[]; // is not present in old files
};
/**
* migrates a url presets from v3 to v4
* - `backstageUrl` -> `url`
* - `backstageInfo` -> `info`
* - drop `publicUrl`
* - drop `publicInfo`
* - ensure `logo`
* - ensure `custom`
*/
export function migrateProjectData(jsonData: object): ProjectData | undefined {
if (is.objectWithKeys(jsonData, ['project']) && is.object(jsonData.project)) {
const { title, description, backstageInfo, backstageUrl, logo, custom } = structuredClone(
jsonData.project,
) as old_ProjectData;
return {
title,
description,
url: backstageUrl,
info: backstageInfo,
logo: logo ?? null,
custom: custom ?? [],
};
}
}
// old key -> new key
type CustomFieldsTranslationTable = Map<string, string>;
type old_CustomFields = Record<
string,
{
type: 'string' | 'image';
colour: string;
label: string;
}
>;
/**
* migrates a custom fields from v3 to v4
* - ensure correct case
* - ensure that the key is derived from the label
* - convert `type` from the string option to the text option
* - create a translation table for the rundown parser
*/
export function migrateCustomFields(
jsonData: object,
): { customFields: CustomFields; translationTable: CustomFieldsTranslationTable } | undefined {
const translationTable: CustomFieldsTranslationTable = new Map();
if (is.objectWithKeys(jsonData, ['customFields']) && is.object(jsonData.customFields)) {
// intentionally cast as any so we can extract the values
const oldCustomFields = structuredClone(jsonData.customFields) as old_CustomFields;
const customFields: CustomFields = {};
for (const [originalKey, field] of Object.entries(oldCustomFields)) {
if (!checkRegex.isAlphanumericWithSpace(field.label)) {
continue;
}
// the key is always made from the label
const key = customFieldLabelToKey(field.label);
if (key in customFields) {
continue;
}
translationTable.set(originalKey, key);
customFields[key] = {
type: field.type === 'string' ? 'text' : field.type,
colour: field.colour,
label: field.label,
};
}
return { customFields, translationTable };
}
}
export type old_OscSubscription = {
id: string;
cycle: TimerLifeCycle;
address: string;
payload: string;
enabled: boolean;
};
export type old_OSCSettings = {
portIn: number;
portOut: number;
targetIP: string;
enabledIn: boolean;
enabledOut: boolean;
subscriptions: old_OscSubscription[];
};
export type old_HttpSubscription = { id: string; cycle: TimerLifeCycle; message: string; enabled: boolean };
export type old_HttpSettings = {
enabledOut: boolean;
subscriptions: old_HttpSubscription[];
};
/**
* migrates a automations from v3 to v4
* - in case of a newer v3 project we can just return Automation settings
* - recover older osc and http subscriptions
*/
export function migrateAutomations(jsonData: object): AutomationSettings | undefined {
if (is.objectWithKeys(jsonData, ['automation']) && is.object(jsonData.automation)) {
// For now the automation type used i v3 and in v4 are the same but we will need to update this if it changes at some point
const oldAutomationSettings = structuredClone(jsonData.automation) as AutomationSettings;
return oldAutomationSettings;
}
let foundOldSetting = false;
const migratedOldStuff: AutomationSettings = getPartialProject('automation');
const migratedAutomations: NormalisedAutomation = {};
const migratedTriggers: Trigger[] = [];
if (is.objectWithKeys(jsonData, ['osc']) && is.object(jsonData.osc)) {
foundOldSetting = true;
const { subscriptions, portIn, enabledIn, targetIP, portOut } = structuredClone(jsonData.osc) as old_OSCSettings;
migratedOldStuff.enabledOscIn = enabledIn;
migratedOldStuff.oscPortIn = portIn;
for (const subscription of subscriptions) {
const { id, cycle, address, payload } = subscription;
migratedTriggers.push({
id: `${id}-T`,
automationId: `${id}-A`,
title: `Migrated Trigger ${id}`,
trigger: cycle,
});
migratedAutomations[`${id}-A`] = {
id: `${id}-A`,
title: `Migrated Automation ${id}`,
filterRule: 'any',
filters: [],
outputs: [{ type: 'osc', address, args: payload, targetIP, targetPort: portOut }],
};
}
}
if (is.objectWithKeys(jsonData, ['http']) && is.object(jsonData.http)) {
foundOldSetting = true;
const { subscriptions } = structuredClone(jsonData.http) as old_HttpSettings;
for (const subscription of subscriptions) {
const { id, cycle, message } = subscription;
migratedTriggers.push({
id: `${id}-T`,
automationId: `${id}-A`,
title: `Migrated Trigger ${id}`,
trigger: cycle,
});
migratedAutomations[`${id}-A`] = {
id: `${id}-A`,
title: `Migrated Automation ${id}`,
filterRule: 'any',
filters: [],
outputs: [{ type: 'http', url: message }],
};
}
}
if (foundOldSetting) {
migratedOldStuff.automations = migratedAutomations;
migratedOldStuff.triggers = migratedTriggers;
return migratedOldStuff;
}
}
/**
* migrates a rundown from v3 to v4
* - name the rundown default and place it in the multi rundown object
* - generate rundown info placeholders (can be somewhat empty as it will be regenerated by the rundown init)
*
* - events:
* - add flag
* - ensure end action is not stop
* - move the timer type count-to-end to it owen setting
* - ensure triggers
* - add parent
*
* - block:
* - rename to group
* - create group data
*/
export function migrateRundown(
jsonData: object,
translationTable: CustomFieldsTranslationTable | undefined,
): ProjectRundowns | undefined {
if (is.objectWithKeys(jsonData, ['rundown']) && is.array(jsonData.rundown)) {
// intentionally cast as any so we can extract the values
const oldRundown = structuredClone(jsonData.rundown) as any[];
const newRundown: Rundown = {
id: 'default',
title: 'Default',
order: [],
flatOrder: [],
entries: {},
revision: 0,
};
let parent = null;
let children: string[] = [];
const append = (entry: OntimeEntry) => {
if ('parent' in entry && entry.parent) {
children.push(entry.id);
} else {
newRundown.order.push(entry.id);
}
newRundown.flatOrder.push(entry.id);
newRundown.entries[entry.id] = entry;
};
for (const entry of oldRundown) {
if (entry.type === 'event') {
const { custom } = entry as { custom: Record<string, string> };
const newCustom: EntryCustomFields = {};
if (translationTable) {
Object.entries(custom).forEach(([key, value]) => {
const newKey = translationTable.get(key);
if (newKey) {
newCustom[newKey] = value;
}
});
}
append({
type: SupportedEntry.Event,
id: entry.id,
flag: false, // new data point
cue: entry.cue,
title: entry.title,
note: entry.note,
endAction: validateEndAction(entry.endAction, EndAction.None), // ensure end action is not stop
timerType: isKnownTimerType(entry.timerType) ? entry.timerType : eventModel.timerType, // ensure the timer type is not count-to-end
countToEnd: entry.timerType === 'count-to-end', // countToEnd was previously a timer type
linkStart: Boolean(entry.linkStart), //this has been null/string
timeStrategy: entry.timeStrategy,
timeStart: entry.timeStart,
timeEnd: entry.timeEnd,
duration: entry.duration,
skip: entry.skip,
colour: entry.colour,
timeWarning: entry.timeWarning,
timeDanger: entry.timeDanger,
custom: newCustom,
triggers: entry.triggers ?? [], // might not be there if the project is a bit older
parent, // new data point
// !==== RUNTIME METADATA ====! //
revision: -1,
delay: 0,
dayOffset: 0 as Day,
gap: 0,
});
} else if (entry.type === 'block') {
if (parent) {
(newRundown.entries[parent] as OntimeGroup).entries = [...children];
children = [];
}
parent = entry.id;
append({
id: entry.id,
type: SupportedEntry.Group,
title: entry.title,
note: '', // leave blank
entries: [], // leave empty
targetDuration: null,
colour: '', //leave default colour
custom: {}, // leave empty
// !==== RUNTIME METADATA ====! //
revision: -1,
timeStart: null,
timeEnd: null,
duration: 0,
isFirstLinked: false,
});
} else if (entry.type === 'delay') {
append({ id: entry.id, type: SupportedEntry.Delay, duration: entry.duration, parent });
}
}
if (parent) {
(newRundown.entries[parent] as OntimeGroup).entries = [...children];
children = [];
}
return {
default: newRundown,
};
}
}