mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-08 08:53: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
600 lines
16 KiB
TypeScript
600 lines
16 KiB
TypeScript
import {
|
|
AutomationSettings,
|
|
CustomFields,
|
|
type Day,
|
|
DatabaseModel,
|
|
EndAction,
|
|
OntimeView,
|
|
ProjectData,
|
|
Rundown,
|
|
Settings,
|
|
SupportedEntry,
|
|
TimeStrategy,
|
|
TimerLifeCycle,
|
|
TimerType,
|
|
URLPreset,
|
|
ViewSettings,
|
|
} from 'ontime-types';
|
|
|
|
import * as v3 from './db.migration.v3.js';
|
|
import * as v4 from './db.migration.v4.js';
|
|
|
|
describe('v3 to v4', () => {
|
|
const oldDb = {
|
|
rundown: [
|
|
{
|
|
id: 'event1',
|
|
type: 'event',
|
|
cue: '123',
|
|
title: 'ABC',
|
|
note: 'DEF',
|
|
endAction: EndAction.None,
|
|
timerType: TimerType.CountDown,
|
|
countToEnd: false,
|
|
linkStart: false,
|
|
timeStrategy: TimeStrategy.LockDuration,
|
|
timeStart: 0,
|
|
timeEnd: 10,
|
|
duration: 10,
|
|
isPublic: false,
|
|
skip: false,
|
|
colour: 'blue',
|
|
timeWarning: 5,
|
|
timeDanger: 2,
|
|
custom: {
|
|
song: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
|
|
doseNotExist: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
|
|
},
|
|
revision: 0,
|
|
delay: 0,
|
|
dayOffset: 0,
|
|
gap: 0,
|
|
},
|
|
{ id: 'group0', type: 'block', title: 'GROUP 0' },
|
|
{
|
|
id: 'event2',
|
|
type: SupportedEntry.Event,
|
|
cue: '124',
|
|
title: 'ABC',
|
|
note: 'DEF',
|
|
endAction: EndAction.None,
|
|
timerType: TimerType.CountDown,
|
|
countToEnd: false,
|
|
linkStart: false,
|
|
timeStrategy: TimeStrategy.LockDuration,
|
|
timeStart: 0,
|
|
timeEnd: 10,
|
|
duration: 10,
|
|
isPublic: false,
|
|
skip: false,
|
|
colour: 'blue',
|
|
timeWarning: 5,
|
|
timeDanger: 2,
|
|
custom: {
|
|
wow: 'http://www.agoodimage.com',
|
|
artist: 'Ib Andersen',
|
|
},
|
|
triggers: [{ id: 'testTrig', title: 'Test trigger', trigger: TimerLifeCycle.onStart, automationId: '1' }],
|
|
revision: 0,
|
|
delay: 0,
|
|
dayOffset: 0,
|
|
gap: 0,
|
|
},
|
|
{
|
|
id: 'event3',
|
|
type: SupportedEntry.Event,
|
|
cue: '125',
|
|
// title: 'ABC', this has a missing field
|
|
note: 'DEF',
|
|
endAction: EndAction.None,
|
|
timerType: TimerType.CountDown,
|
|
countToEnd: false,
|
|
linkStart: false,
|
|
timeStrategy: TimeStrategy.LockDuration,
|
|
timeStart: 0,
|
|
timeEnd: 10,
|
|
duration: 10,
|
|
isPublic: false,
|
|
skip: false,
|
|
colour: 'blue',
|
|
timeWarning: 5,
|
|
timeDanger: 2,
|
|
custom: {
|
|
wow: 'http://www.agoodimage.com',
|
|
artist: 'Ib Andersen',
|
|
},
|
|
triggers: [{ id: 'testTrig', title: 'Test trigger', trigger: TimerLifeCycle.onStart, automationId: '1' }],
|
|
revision: 0,
|
|
delay: 0,
|
|
dayOffset: 0,
|
|
gap: 0,
|
|
},
|
|
{ id: 'group1', type: 'block', title: 'GROUP 1' },
|
|
{ id: 'delay', type: 'delay', duration: 1000 },
|
|
],
|
|
project: {
|
|
title: 'Eurovision Song Contest',
|
|
description: 'Turin 2022',
|
|
publicUrl: '123',
|
|
publicInfo: '456',
|
|
backstageUrl: 'www.github.com/cpvalente/ontime',
|
|
backstageInfo: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
|
|
},
|
|
settings: {
|
|
version: '3.3.3',
|
|
serverPort: 4001,
|
|
editorKey: null,
|
|
operatorKey: null,
|
|
timeFormat: '24',
|
|
language: 'en',
|
|
},
|
|
viewSettings: {
|
|
overrideStyles: false,
|
|
normalColor: '#ffffffcc',
|
|
warningColor: '#ffa528',
|
|
dangerColor: '#ff7300',
|
|
freezeEnd: false,
|
|
endMessage: '',
|
|
},
|
|
urlPresets: [
|
|
{
|
|
enabled: true,
|
|
alias: 'clock',
|
|
pathAndParams:
|
|
'timer?showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
|
},
|
|
{
|
|
enabled: true,
|
|
alias: 'minimal',
|
|
pathAndParams:
|
|
'timer?showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
|
},
|
|
],
|
|
customFields: {
|
|
song: {
|
|
label: 'Song and Dance',
|
|
type: 'string',
|
|
colour: '#339E4E',
|
|
},
|
|
artist: {
|
|
label: 'Artist and Host',
|
|
type: 'string',
|
|
colour: '#3E75E8',
|
|
},
|
|
wow: {
|
|
label: 'WOW 123',
|
|
type: 'image',
|
|
colour: '#E80000',
|
|
},
|
|
},
|
|
automation: {
|
|
enabledAutomations: true,
|
|
enabledOscIn: true,
|
|
oscPortIn: 8888,
|
|
triggers: [],
|
|
automations: {},
|
|
},
|
|
};
|
|
|
|
test('migrate settings', () => {
|
|
const expectSettings: Settings & { serverPort: number } = {
|
|
version: '4.0.0',
|
|
serverPort: 4001,
|
|
editorKey: null,
|
|
operatorKey: null,
|
|
timeFormat: '24',
|
|
language: 'en',
|
|
};
|
|
const newSettings = v3.migrateSettings(oldDb);
|
|
expect(newSettings).toEqual(expectSettings);
|
|
});
|
|
|
|
test('migrate view settings', () => {
|
|
const expectViewSettings: ViewSettings = {
|
|
dangerColor: '#ff7300',
|
|
normalColor: '#ffffffcc',
|
|
overrideStyles: false,
|
|
warningColor: '#ffa528',
|
|
};
|
|
const newViewSettings = v3.migrateViewSettings(oldDb);
|
|
expect(newViewSettings).toEqual(expectViewSettings);
|
|
});
|
|
|
|
test('migrate url preset', () => {
|
|
const expectUrlPresets: URLPreset[] = [
|
|
{
|
|
enabled: true,
|
|
alias: 'clock',
|
|
target: OntimeView.Timer,
|
|
search:
|
|
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
|
displayInNav: false,
|
|
options: {},
|
|
},
|
|
{
|
|
enabled: true,
|
|
alias: 'minimal',
|
|
target: OntimeView.Timer,
|
|
search:
|
|
'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
|
displayInNav: false,
|
|
options: {},
|
|
},
|
|
];
|
|
const newUrlPreset = v3.migrateURLPresets(oldDb);
|
|
expect(newUrlPreset).toEqual(expectUrlPresets);
|
|
});
|
|
|
|
test('migrate project data', () => {
|
|
const expectProjectData: ProjectData = {
|
|
title: 'Eurovision Song Contest',
|
|
description: 'Turin 2022',
|
|
url: 'www.github.com/cpvalente/ontime',
|
|
info: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
|
|
logo: null,
|
|
custom: [],
|
|
};
|
|
const newProjectData = v3.migrateProjectData(oldDb);
|
|
expect(newProjectData).toEqual(expectProjectData);
|
|
});
|
|
|
|
test('migrate custom fields', () => {
|
|
const expectCustomFields: CustomFields = {
|
|
Song_and_Dance: {
|
|
label: 'Song and Dance',
|
|
type: 'text',
|
|
colour: '#339E4E',
|
|
},
|
|
Artist_and_Host: {
|
|
label: 'Artist and Host',
|
|
type: 'text',
|
|
colour: '#3E75E8',
|
|
},
|
|
WOW_123: {
|
|
label: 'WOW 123',
|
|
type: 'image',
|
|
colour: '#E80000',
|
|
},
|
|
};
|
|
const parsedData = v3.migrateCustomFields(oldDb);
|
|
expect(parsedData).not.toBeUndefined();
|
|
expect(parsedData?.customFields).toEqual(expectCustomFields);
|
|
expect(parsedData?.translationTable).toEqual(
|
|
new Map([
|
|
['song', 'Song_and_Dance'],
|
|
['artist', 'Artist_and_Host'],
|
|
['wow', 'WOW_123'],
|
|
]),
|
|
);
|
|
});
|
|
|
|
test('migrate rundown', () => {
|
|
const expectedRundown: Rundown = {
|
|
id: 'default',
|
|
title: 'Default',
|
|
order: ['event1', 'group0', 'group1'],
|
|
flatOrder: ['event1', 'group0', 'event2', 'event3', 'group1', 'delay'],
|
|
entries: {
|
|
event1: {
|
|
id: 'event1',
|
|
type: SupportedEntry.Event,
|
|
cue: '123',
|
|
title: 'ABC',
|
|
note: 'DEF',
|
|
endAction: EndAction.None,
|
|
timerType: TimerType.CountDown,
|
|
countToEnd: false,
|
|
linkStart: false,
|
|
timeStrategy: TimeStrategy.LockDuration,
|
|
timeStart: 0,
|
|
timeEnd: 10,
|
|
duration: 10,
|
|
skip: false,
|
|
colour: 'blue',
|
|
timeWarning: 5,
|
|
timeDanger: 2,
|
|
custom: { Song_and_Dance: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit' },
|
|
triggers: [],
|
|
revision: -1,
|
|
flag: false,
|
|
parent: null,
|
|
delay: 0,
|
|
dayOffset: 0 as Day,
|
|
gap: 0,
|
|
},
|
|
group0: {
|
|
id: 'group0',
|
|
type: SupportedEntry.Group,
|
|
title: 'GROUP 0',
|
|
colour: '',
|
|
custom: {},
|
|
duration: 0,
|
|
entries: ['event2', 'event3'],
|
|
isFirstLinked: false,
|
|
note: '',
|
|
revision: -1,
|
|
targetDuration: null,
|
|
timeEnd: null,
|
|
timeStart: null,
|
|
useGroupTimer: false,
|
|
},
|
|
event2: {
|
|
id: 'event2',
|
|
type: SupportedEntry.Event,
|
|
cue: '124',
|
|
title: 'ABC',
|
|
note: 'DEF',
|
|
endAction: EndAction.None,
|
|
timerType: TimerType.CountDown,
|
|
countToEnd: false,
|
|
linkStart: false,
|
|
timeStrategy: TimeStrategy.LockDuration,
|
|
timeStart: 0,
|
|
timeEnd: 10,
|
|
duration: 10,
|
|
skip: false,
|
|
colour: 'blue',
|
|
timeWarning: 5,
|
|
timeDanger: 2,
|
|
custom: {
|
|
WOW_123: 'http://www.agoodimage.com',
|
|
Artist_and_Host: 'Ib Andersen',
|
|
},
|
|
triggers: [{ id: 'testTrig', title: 'Test trigger', trigger: TimerLifeCycle.onStart, automationId: '1' }],
|
|
flag: false,
|
|
parent: 'group0',
|
|
revision: -1,
|
|
delay: 0,
|
|
dayOffset: 0 as Day,
|
|
gap: 0,
|
|
},
|
|
event3: {
|
|
id: 'event3',
|
|
type: SupportedEntry.Event,
|
|
cue: '125',
|
|
//@ts-expect-error - if a field is missing we should pass it through a let our normal import fix it
|
|
title: undefined,
|
|
note: 'DEF',
|
|
endAction: EndAction.None,
|
|
timerType: TimerType.CountDown,
|
|
countToEnd: false,
|
|
linkStart: false,
|
|
timeStrategy: TimeStrategy.LockDuration,
|
|
timeStart: 0,
|
|
timeEnd: 10,
|
|
duration: 10,
|
|
skip: false,
|
|
colour: 'blue',
|
|
timeWarning: 5,
|
|
timeDanger: 2,
|
|
custom: {
|
|
WOW_123: 'http://www.agoodimage.com',
|
|
Artist_and_Host: 'Ib Andersen',
|
|
},
|
|
triggers: [{ id: 'testTrig', title: 'Test trigger', trigger: TimerLifeCycle.onStart, automationId: '1' }],
|
|
flag: false,
|
|
parent: 'group0',
|
|
revision: -1,
|
|
delay: 0,
|
|
dayOffset: 0 as Day,
|
|
gap: 0,
|
|
},
|
|
group1: {
|
|
id: 'group1',
|
|
type: SupportedEntry.Group,
|
|
title: 'GROUP 1',
|
|
colour: '',
|
|
custom: {},
|
|
duration: 0,
|
|
entries: ['delay'],
|
|
isFirstLinked: false,
|
|
note: '',
|
|
revision: -1,
|
|
targetDuration: null,
|
|
timeEnd: null,
|
|
timeStart: null,
|
|
useGroupTimer: false,
|
|
},
|
|
delay: {
|
|
type: SupportedEntry.Delay,
|
|
id: 'delay',
|
|
duration: 1000,
|
|
parent: 'group1',
|
|
},
|
|
},
|
|
|
|
revision: 0,
|
|
};
|
|
const translationTable = new Map([
|
|
['song', 'Song_and_Dance'],
|
|
['artist', 'Artist_and_Host'],
|
|
['wow', 'WOW_123'],
|
|
]);
|
|
|
|
//@ts-expect-error - we know the default rundown should appear
|
|
expect(v3.migrateRundown(oldDb, translationTable)['default']).toStrictEqual(expectedRundown);
|
|
});
|
|
|
|
describe('migrate old automation', () => {
|
|
test('osc', () => {
|
|
const oldData = {
|
|
portIn: 8881,
|
|
portOut: 55890,
|
|
targetIP: '127.0.0.1',
|
|
enabledIn: true,
|
|
enabledOut: true,
|
|
subscriptions: [
|
|
{
|
|
id: '23f4d8',
|
|
cycle: 'onClock',
|
|
address: '/test',
|
|
payload: 'bip',
|
|
enabled: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
const expectedAutomation: AutomationSettings = {
|
|
enabledAutomations: true,
|
|
enabledOscIn: true,
|
|
oscPortIn: 8881,
|
|
triggers: [
|
|
{
|
|
id: '23f4d8-T',
|
|
title: 'Migrated Trigger 23f4d8',
|
|
trigger: TimerLifeCycle.onClock,
|
|
automationId: '23f4d8-A',
|
|
},
|
|
],
|
|
automations: {
|
|
'23f4d8-A': {
|
|
id: '23f4d8-A',
|
|
title: 'Migrated Automation 23f4d8',
|
|
filterRule: 'any',
|
|
filters: [],
|
|
outputs: [{ type: 'osc', targetIP: '127.0.0.1', targetPort: 55890, address: '/test', args: 'bip' }],
|
|
},
|
|
},
|
|
};
|
|
|
|
expect(v3.migrateAutomations({ osc: oldData })).toStrictEqual(expectedAutomation);
|
|
});
|
|
test('http', () => {
|
|
const oldData = {
|
|
enabledOut: true,
|
|
subscriptions: [
|
|
{
|
|
id: '1ge4r8',
|
|
cycle: 'onClock',
|
|
message: 'http://www.test.com',
|
|
enabled: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
const expectedAutomation: AutomationSettings = {
|
|
enabledAutomations: true,
|
|
enabledOscIn: false,
|
|
oscPortIn: 8888,
|
|
triggers: [
|
|
{
|
|
id: '1ge4r8-T',
|
|
title: 'Migrated Trigger 1ge4r8',
|
|
trigger: TimerLifeCycle.onClock,
|
|
automationId: '1ge4r8-A',
|
|
},
|
|
],
|
|
automations: {
|
|
'1ge4r8-A': {
|
|
id: '1ge4r8-A',
|
|
title: 'Migrated Automation 1ge4r8',
|
|
filterRule: 'any',
|
|
filters: [],
|
|
outputs: [{ type: 'http', url: 'http://www.test.com' }],
|
|
},
|
|
},
|
|
};
|
|
|
|
expect(v3.migrateAutomations({ http: oldData })).toStrictEqual(expectedAutomation);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('v4 remove server port', () => {
|
|
const demoDb = {
|
|
rundowns: {},
|
|
project: {
|
|
title: 'Eurovision Song Contest',
|
|
description: 'Turin 2022',
|
|
url: 'www.github.com/cpvalente/ontime',
|
|
info: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
|
|
logo: null,
|
|
custom: [],
|
|
},
|
|
settings: {
|
|
app: 'ontime',
|
|
version: '4.0.0',
|
|
serverPort: 4001,
|
|
editorKey: null,
|
|
operatorKey: null,
|
|
timeFormat: '24',
|
|
language: 'en',
|
|
},
|
|
viewSettings: {
|
|
dangerColor: '#ff7300',
|
|
normalColor: '#ffffffcc',
|
|
overrideStyles: false,
|
|
warningColor: '#ffa528',
|
|
},
|
|
customFields: {
|
|
song: {
|
|
label: 'Song',
|
|
type: 'text',
|
|
colour: '#339E4E',
|
|
},
|
|
artist: {
|
|
label: 'Artist',
|
|
type: 'text',
|
|
colour: '#3E75E8',
|
|
},
|
|
},
|
|
urlPresets: [
|
|
{
|
|
enabled: true,
|
|
alias: 'clock',
|
|
target: 'timer',
|
|
search:
|
|
'timer?showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
|
},
|
|
{
|
|
enabled: true,
|
|
alias: 'minimal',
|
|
target: 'timer',
|
|
search:
|
|
'timer?showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
|
},
|
|
],
|
|
automation: {
|
|
enabledAutomations: false,
|
|
enabledOscIn: true,
|
|
oscPortIn: 8888,
|
|
triggers: [],
|
|
automations: {},
|
|
},
|
|
};
|
|
|
|
it('should migrate if server port exists', () => {
|
|
expect(v4.shouldMigrateServerPort(demoDb)).toBeTruthy();
|
|
});
|
|
|
|
test('should not migrate if the version is newer', () => {
|
|
expect(
|
|
v4.shouldMigrateServerPort({ settings: { version: '5.0.0', serverPort: 4001 } } as unknown as DatabaseModel),
|
|
).toBeFalsy();
|
|
});
|
|
|
|
test('should not migrate if there is no server port', () => {
|
|
expect(v4.shouldMigrateServerPort({ settings: { version: '4.0.0' } } as DatabaseModel)).toBeFalsy();
|
|
});
|
|
|
|
it('remove server port from project', () => {
|
|
const { db: result, serverPort } = v4.migrateServerPort(demoDb as DatabaseModel);
|
|
expect(result).not.toHaveProperty('settings.serverPort');
|
|
expect(serverPort).toBe(4001);
|
|
expect(result.automation).toMatchObject(demoDb.automation);
|
|
expect(result.customFields).toMatchObject(demoDb.customFields);
|
|
expect(result.project).toMatchObject(demoDb.project);
|
|
expect(result.rundowns).toMatchObject(demoDb.rundowns);
|
|
expect(result.settings).toMatchObject({
|
|
app: 'ontime',
|
|
version: '4.5.0',
|
|
editorKey: null,
|
|
operatorKey: null,
|
|
timeFormat: '24',
|
|
language: 'en',
|
|
});
|
|
expect(result.urlPresets).toMatchObject(demoDb.urlPresets);
|
|
expect(result.viewSettings).toMatchObject(demoDb.viewSettings);
|
|
});
|
|
});
|