mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 09:23:51 +00:00
636b2e7c3e
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
437 lines
13 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|