mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 17:33:55 +00:00
59bff62f76
- 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
444 lines
13 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|