Files
ontime/apps/server/src/api-data/db/migration/db.migration.v3.ts
T
Claude 636b2e7c3e feat(rundown): add opt-in shared group timer
Groups can now opt into a shared timer, so stage outputs show how much time
the whole block has left instead of only the running event.

The value is derived in the client from the running event timer plus the
content still scheduled after it, which makes the group behave as if it were
a single event containing all its children. Keeping it relative to the event
timer (rather than recalculating from the clock) means pause, added time,
overtime, roll and midnight rollovers are all inherited, and moving between
events inside a group cannot introduce a discontinuity.

The remainder aggregation mirrors the server side group duration calculation:
non playable entries are skipped and gaps are accounted for.

Warning and danger thresholds belong to the event and carry no meaning against
a group duration, so they are suppressed while the group timer is showing.
Overtime is kept, but only once the group itself has run out of time.

- add OntimeGroup.useGroupTimer, defaulting to false
- show the group timer in the timer view and the PiP timer
- indicate the override in the timer view, PiP, group header and timer preview
- add a switch to the group editor

The PiP window mounts its own react root, which had no query client and could
therefore not read the rundown. It is now wrapped in a provider, which also
means its view settings are no longer frozen at the moment the window opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kniqs443KUNCRABwVJwT7K
2026-07-26 06:24:15 +00:00

437 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 };
}
}
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
useGroupTimer: false, // opt-in feature, off for migrated projects
// !==== 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,
};
}
}