mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-11 18:33:53 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3a7cd6a39 | |||
| 39e6c15adf | |||
| aec6673b07 | |||
| 9e8cb1c755 | |||
| cdf02f7c3f | |||
| 77779a4648 | |||
| e16ec3f388 | |||
| 63cfc9ec19 | |||
| 4827117163 | |||
| 5777ef3532 | |||
| 80a3b04493 | |||
| 7c5fdd3b19 | |||
| 31fd8e5200 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "4.4.0",
|
"version": "4.4.2",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "4.4.0",
|
"version": "4.4.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { Tooltip } from '@base-ui/react/tooltip';
|
|||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
|
|
||||||
|
// apply global axios config defaults
|
||||||
|
import './common/api/axios.config';
|
||||||
|
|
||||||
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
|
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
|
||||||
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
|
import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
|
||||||
import { AppContextProvider } from './common/context/AppContext';
|
import { AppContextProvider } from './common/context/AppContext';
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import { TranslationObject } from 'ontime-types';
|
|||||||
import { ontimeQueryClient } from '../../common/queryClient';
|
import { ontimeQueryClient } from '../../common/queryClient';
|
||||||
|
|
||||||
import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
|
import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
const assetsPath = `${apiEntryUrl}/assets`;
|
const assetsPath = `${apiEntryUrl}/assets`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to get css contents
|
* HTTP request to get css contents
|
||||||
*/
|
*/
|
||||||
export async function getCSSContents(): Promise<string> {
|
export async function getCSSContents(options?: RequestOptions): Promise<string> {
|
||||||
const res = await axios.get(`${assetsPath}/css`);
|
const res = await axios.get(`${assetsPath}/css`, { signal: options?.signal });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,8 +36,8 @@ export async function restoreCSSContents(): Promise<string> {
|
|||||||
/**
|
/**
|
||||||
* HTTP request to get user translation
|
* HTTP request to get user translation
|
||||||
*/
|
*/
|
||||||
export async function getUserTranslation(): Promise<TranslationObject> {
|
export async function getUserTranslation(options?: RequestOptions): Promise<TranslationObject> {
|
||||||
const res = await axios.get(customTranslationsURL);
|
const res = await axios.get(customTranslationsURL, { signal: options?.signal });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ import type {
|
|||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
const automationsPath = `${apiEntryUrl}/automations`;
|
const automationsPath = `${apiEntryUrl}/automations`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to get the automations settings
|
* HTTP request to get the automations settings
|
||||||
*/
|
*/
|
||||||
export async function getAutomationSettings(): Promise<AutomationSettings> {
|
export async function getAutomationSettings(options?: RequestOptions): Promise<AutomationSettings> {
|
||||||
const res = await axios.get(automationsPath);
|
const res = await axios.get(automationsPath, { signal: options?.signal });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
import { axiosConfig } from './requestTimeouts';
|
||||||
|
|
||||||
axios.defaults.validateStatus = (status) => {
|
axios.defaults.validateStatus = (status) => {
|
||||||
return (status >= 200 && status < 300) || status === 304;
|
return status >= 200 && status < 300;
|
||||||
};
|
};
|
||||||
|
axios.defaults.timeout = axiosConfig.shortTimeout;
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import axios from 'axios';
|
|||||||
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
|
import { CustomField, CustomFieldKey, CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
const customFieldsPath = `${apiEntryUrl}/custom-fields`;
|
const customFieldsPath = `${apiEntryUrl}/custom-fields`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requests list of known custom fields
|
* Requests list of known custom fields
|
||||||
*/
|
*/
|
||||||
export async function getCustomFields(): Promise<CustomFields> {
|
export async function getCustomFields(options?: RequestOptions): Promise<CustomFields> {
|
||||||
const res = await axios.get(customFieldsPath);
|
const res = await axios.get(customFieldsPath, { signal: options?.signal });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import axios, { AxiosResponse } from 'axios';
|
|||||||
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
|
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
import { axiosConfig } from './requestTimeouts';
|
||||||
import { createBlob, downloadBlob } from './utils';
|
import { createBlob, downloadBlob } from './utils';
|
||||||
|
|
||||||
const dbPath = `${apiEntryUrl}/db`;
|
const dbPath = `${apiEntryUrl}/db`;
|
||||||
@@ -10,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
|
|||||||
* HTTP request to the current DB
|
* HTTP request to the current DB
|
||||||
*/
|
*/
|
||||||
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
|
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
|
||||||
return axios.post(`${dbPath}/download`, { filename });
|
return axios.post(`${dbPath}/download`, { filename }, { timeout: axiosConfig.longTimeout });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,6 +39,7 @@ export async function uploadProjectFile(file: File): Promise<MessageResponse> {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('project', file);
|
formData.append('project', file);
|
||||||
const response = await axios.post(`${dbPath}/upload`, formData, {
|
const response = await axios.post(`${dbPath}/upload`, formData, {
|
||||||
|
timeout: axiosConfig.longTimeout,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
@@ -76,8 +79,11 @@ export async function quickProject(data: QuickStartData): Promise<MessageRespons
|
|||||||
/**
|
/**
|
||||||
* HTTP request to get the list of available project files
|
* HTTP request to get the list of available project files
|
||||||
*/
|
*/
|
||||||
export async function getProjects(): Promise<ProjectFileListResponse> {
|
export async function getProjects(options?: RequestOptions): Promise<ProjectFileListResponse> {
|
||||||
const res = await axios.get(`${dbPath}/all`);
|
const res = await axios.get(`${dbPath}/all`, {
|
||||||
|
signal: options?.signal,
|
||||||
|
timeout: options?.timeout,
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
|
|||||||
import { ImportMap } from 'ontime-utils';
|
import { ImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
import { axiosConfig } from './requestTimeouts';
|
||||||
import { downloadBlob } from './utils';
|
import { downloadBlob } from './utils';
|
||||||
|
|
||||||
const excelPath = `${apiEntryUrl}/excel`;
|
const excelPath = `${apiEntryUrl}/excel`;
|
||||||
@@ -11,10 +13,12 @@ const excelPath = `${apiEntryUrl}/excel`;
|
|||||||
* upload Excel file to server
|
* upload Excel file to server
|
||||||
* @return string - file ID op the uploaded file
|
* @return string - file ID op the uploaded file
|
||||||
*/
|
*/
|
||||||
export async function upload(file: File): Promise<string[]> {
|
export async function upload(file: File, requestOptions?: RequestOptions): Promise<string[]> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('excel', file);
|
formData.append('excel', file);
|
||||||
const response = await axios.post(`${excelPath}/upload`, formData, {
|
const response = await axios.post(`${excelPath}/upload`, formData, {
|
||||||
|
signal: requestOptions?.signal,
|
||||||
|
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
@@ -27,19 +31,31 @@ type PreviewSpreadsheetResponse = {
|
|||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
summary: RundownSummary;
|
summary: RundownSummary;
|
||||||
};
|
};
|
||||||
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
|
export async function importRundownPreview(
|
||||||
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
|
options: ImportMap,
|
||||||
options,
|
requestOptions?: RequestOptions,
|
||||||
});
|
): Promise<PreviewSpreadsheetResponse> {
|
||||||
|
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(
|
||||||
|
`${excelPath}/preview`,
|
||||||
|
{
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
signal: requestOptions?.signal,
|
||||||
|
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||||
|
},
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads a xlsx representation of the rundown from the server
|
* Downloads a xlsx representation of the rundown from the server
|
||||||
*/
|
*/
|
||||||
export async function downloadAsExcel(rundownId: string, fileName?: string) {
|
export async function downloadAsExcel(rundownId: string, fileName?: string, requestOptions?: RequestOptions) {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${excelPath}/${rundownId}/export`, {
|
const response = await axios.get(`${excelPath}/${rundownId}/export`, {
|
||||||
|
signal: requestOptions?.signal,
|
||||||
|
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import axios from 'axios';
|
|||||||
|
|
||||||
import { apiRepoLatest } from '../../externals';
|
import { apiRepoLatest } from '../../externals';
|
||||||
|
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
export type HasUpdate = {
|
export type HasUpdate = {
|
||||||
url: string;
|
url: string;
|
||||||
version: string;
|
version: string;
|
||||||
@@ -10,8 +12,8 @@ export type HasUpdate = {
|
|||||||
/**
|
/**
|
||||||
* HTTP request to get the latest version and url from github
|
* HTTP request to get the latest version and url from github
|
||||||
*/
|
*/
|
||||||
export async function getLatestVersion(): Promise<HasUpdate> {
|
export async function getLatestVersion(options?: RequestOptions): Promise<HasUpdate> {
|
||||||
const res = await axios.get(apiRepoLatest);
|
const res = await axios.get(apiRepoLatest, { signal: options?.signal });
|
||||||
return {
|
return {
|
||||||
url: res.data.html_url as string,
|
url: res.data.html_url as string,
|
||||||
version: res.data.tag_name as string,
|
version: res.data.tag_name as string,
|
||||||
|
|||||||
@@ -2,14 +2,19 @@ import axios, { AxiosResponse } from 'axios';
|
|||||||
import { ProjectData, ProjectLogoResponse } from 'ontime-types';
|
import { ProjectData, ProjectLogoResponse } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
import { axiosConfig } from './requestTimeouts';
|
||||||
|
|
||||||
const projectPath = `${apiEntryUrl}/project`;
|
const projectPath = `${apiEntryUrl}/project`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to fetch project data
|
* HTTP request to fetch project data
|
||||||
*/
|
*/
|
||||||
export async function getProjectData(): Promise<ProjectData> {
|
export async function getProjectData(options?: RequestOptions): Promise<ProjectData> {
|
||||||
const res = await axios.get(projectPath);
|
const res = await axios.get(projectPath, {
|
||||||
|
signal: options?.signal,
|
||||||
|
timeout: options?.timeout,
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +32,7 @@ export async function uploadProjectLogo(file: File): Promise<AxiosResponse<Proje
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('image', file);
|
formData.append('image', file);
|
||||||
const response = await axios.post(`${projectPath}/upload`, formData, {
|
const response = await axios.post(`${projectPath}/upload`, formData, {
|
||||||
|
timeout: axiosConfig.longTimeout,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import { OntimeReport } from 'ontime-types';
|
|||||||
import { ontimeQueryClient } from '../../common/queryClient';
|
import { ontimeQueryClient } from '../../common/queryClient';
|
||||||
|
|
||||||
import { apiEntryUrl, REPORT } from './constants';
|
import { apiEntryUrl, REPORT } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
export const reportUrl = `${apiEntryUrl}/report`;
|
export const reportUrl = `${apiEntryUrl}/report`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to fetch all reports
|
* HTTP request to fetch all reports
|
||||||
*/
|
*/
|
||||||
export async function fetchReport(): Promise<OntimeReport> {
|
export async function fetchReport(options?: RequestOptions): Promise<OntimeReport> {
|
||||||
const res = await axios.get(reportUrl);
|
const res = await axios.get(reportUrl, { signal: options?.signal });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type RequestOptions = {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
timeout?: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Keep a short global timeout for regular API requests, but allow
|
||||||
|
* longer windows for file transfer and heavy import/export operations.
|
||||||
|
*/
|
||||||
|
export const axiosConfig = {
|
||||||
|
shortTimeout: 20 * 1000, // 20 seconds
|
||||||
|
longTimeout: 3 * 60 * 1000, // 3 minutes
|
||||||
|
} as const;
|
||||||
@@ -2,6 +2,7 @@ import axios, { AxiosResponse } from 'axios';
|
|||||||
import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
|
import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
type RundownId = string;
|
type RundownId = string;
|
||||||
const rundownPath = `${apiEntryUrl}/rundowns`;
|
const rundownPath = `${apiEntryUrl}/rundowns`;
|
||||||
@@ -11,16 +12,16 @@ const rundownPath = `${apiEntryUrl}/rundowns`;
|
|||||||
/**
|
/**
|
||||||
* HTTP request to fetch a list of existing rundowns
|
* HTTP request to fetch a list of existing rundowns
|
||||||
*/
|
*/
|
||||||
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
|
export async function fetchProjectRundownList(options?: RequestOptions): Promise<ProjectRundownsList> {
|
||||||
const res = await axios.get(rundownPath);
|
const res = await axios.get(rundownPath, { signal: options?.signal });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to fetch all entries in the currently loaded rundown
|
* HTTP request to fetch all entries in the currently loaded rundown
|
||||||
*/
|
*/
|
||||||
export async function fetchCurrentRundown(): Promise<Rundown> {
|
export async function fetchCurrentRundown(options?: RequestOptions): Promise<Rundown> {
|
||||||
const res = await axios.get(`${rundownPath}/current`);
|
const res = await axios.get(`${rundownPath}/current`, { signal: options?.signal });
|
||||||
if (!isValidRundown(res.data)) {
|
if (!isValidRundown(res.data)) {
|
||||||
throw new Error('Invalid rundown payload');
|
throw new Error('Invalid rundown payload');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import axios from 'axios';
|
|||||||
import { GetInfo, LinkOptions } from 'ontime-types';
|
import { GetInfo, LinkOptions } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
const sessionPath = `${apiEntryUrl}/session`;
|
const sessionPath = `${apiEntryUrl}/session`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to retrieve application info
|
* HTTP request to retrieve application info
|
||||||
*/
|
*/
|
||||||
export async function getInfo(): Promise<GetInfo> {
|
export async function getInfo(options?: RequestOptions): Promise<GetInfo> {
|
||||||
const res = await axios.get(`${sessionPath}/info`);
|
const res = await axios.get(`${sessionPath}/info`, { signal: options?.signal });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import axios, { AxiosResponse } from 'axios';
|
|||||||
import { Settings } from 'ontime-types';
|
import { Settings } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
const settingsPath = `${apiEntryUrl}/settings`;
|
const settingsPath = `${apiEntryUrl}/settings`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to retrieve application settings
|
* HTTP request to retrieve application settings
|
||||||
*/
|
*/
|
||||||
export async function getSettings(): Promise<Settings> {
|
export async function getSettings(options?: RequestOptions): Promise<Settings> {
|
||||||
const res = await axios.get(settingsPath);
|
const res = await axios.get(settingsPath, { signal: options?.signal });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ont
|
|||||||
import { ImportMap } from 'ontime-utils';
|
import { ImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
import { axiosConfig } from './requestTimeouts';
|
||||||
|
|
||||||
const sheetsPath = `${apiEntryUrl}/sheets`;
|
const sheetsPath = `${apiEntryUrl}/sheets`;
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ export const verifyAuthenticationStatus = async (): Promise<{
|
|||||||
export const requestConnection = async (
|
export const requestConnection = async (
|
||||||
file: File,
|
file: File,
|
||||||
sheetId: string,
|
sheetId: string,
|
||||||
|
requestOptions?: RequestOptions,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
verification_url: string;
|
verification_url: string;
|
||||||
user_code: string;
|
user_code: string;
|
||||||
@@ -31,6 +34,8 @@ export const requestConnection = async (
|
|||||||
formData.append('client_secret', file);
|
formData.append('client_secret', file);
|
||||||
|
|
||||||
const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, {
|
const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, {
|
||||||
|
signal: requestOptions?.signal,
|
||||||
|
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
@@ -53,24 +58,50 @@ export const revokeAuthentication = async (): Promise<{ authenticated: Authentic
|
|||||||
export const previewRundown = async (
|
export const previewRundown = async (
|
||||||
sheetId: string,
|
sheetId: string,
|
||||||
options: ImportMap,
|
options: ImportMap,
|
||||||
|
requestOptions?: RequestOptions,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
rundown: Rundown;
|
rundown: Rundown;
|
||||||
customFields: CustomFields;
|
customFields: CustomFields;
|
||||||
summary: RundownSummary;
|
summary: RundownSummary;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
|
const response = await axios.post(
|
||||||
|
`${sheetsPath}/${sheetId}/read`,
|
||||||
|
{ options },
|
||||||
|
{
|
||||||
|
signal: requestOptions?.signal,
|
||||||
|
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||||
|
},
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getWorksheetNames = async (sheetId: string): Promise<string[]> => {
|
export const getWorksheetNames = async (sheetId: string, requestOptions?: RequestOptions): Promise<string[]> => {
|
||||||
const response: AxiosResponse<string[]> = await axios.post(`${sheetsPath}/${sheetId}/worksheets`);
|
const response: AxiosResponse<string[]> = await axios.post(
|
||||||
|
`${sheetsPath}/${sheetId}/worksheets`,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
signal: requestOptions?.signal,
|
||||||
|
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||||
|
},
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to upload the rundown to a google sheet
|
* HTTP request to upload the rundown to a google sheet
|
||||||
*/
|
*/
|
||||||
export const uploadRundown = async (sheetId: string, options: ImportMap): Promise<void> => {
|
export const uploadRundown = async (
|
||||||
const response = await axios.post(`${sheetsPath}/${sheetId}/write`, { options });
|
sheetId: string,
|
||||||
|
options: ImportMap,
|
||||||
|
requestOptions?: RequestOptions,
|
||||||
|
): Promise<void> => {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${sheetsPath}/${sheetId}/write`,
|
||||||
|
{ options },
|
||||||
|
{
|
||||||
|
signal: requestOptions?.signal,
|
||||||
|
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||||
|
},
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import axios from 'axios';
|
|||||||
import { URLPreset } from 'ontime-types';
|
import { URLPreset } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
|
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to retrieve all presets
|
* HTTP request to retrieve all presets
|
||||||
*/
|
*/
|
||||||
export async function getUrlPresets(): Promise<URLPreset[]> {
|
export async function getUrlPresets(options?: RequestOptions): Promise<URLPreset[]> {
|
||||||
const res = await axios.get(urlPresetsPath);
|
const res = await axios.get(urlPresetsPath, { signal: options?.signal });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import axios from 'axios';
|
|||||||
import type { ViewSettings } from 'ontime-types';
|
import type { ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
|
||||||
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
|
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
|
||||||
|
|
||||||
@@ -9,8 +10,8 @@ const viewSettingsPath = `${apiEntryUrl}/view-settings`;
|
|||||||
* HTTP request to fetch view settings
|
* HTTP request to fetch view settings
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export async function getViewSettings(): Promise<ViewSettings> {
|
export async function getViewSettings(options?: RequestOptions): Promise<ViewSettings> {
|
||||||
const res = await axios.get(viewSettingsPath);
|
const res = await axios.get(viewSettingsPath, { signal: options?.signal });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function useAppVersion() {
|
|||||||
refetch,
|
refetch,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: APP_VERSION,
|
queryKey: APP_VERSION,
|
||||||
queryFn: getLatestVersion,
|
queryFn: ({ signal }) => getLatestVersion({ signal }),
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
|
|||||||
@@ -8,12 +8,9 @@ import { automationPlaceholderSettings } from '../models/AutomationSettings';
|
|||||||
export default function useAutomationSettings() {
|
export default function useAutomationSettings() {
|
||||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||||
queryKey: AUTOMATION,
|
queryKey: AUTOMATION,
|
||||||
queryFn: getAutomationSettings,
|
queryFn: ({ signal }) => getAutomationSettings({ signal }),
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
retry: 5,
|
|
||||||
retryDelay: (attempt: number) => attempt * 2500,
|
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
networkMode: 'always',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
|
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
|
||||||
|
|||||||
@@ -10,12 +10,9 @@ const placeholder: CustomFields = {};
|
|||||||
export default function useCustomFields() {
|
export default function useCustomFields() {
|
||||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||||
queryKey: CUSTOM_FIELDS,
|
queryKey: CUSTOM_FIELDS,
|
||||||
queryFn: getCustomFields,
|
queryFn: ({ signal }) => getCustomFields({ signal }),
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
retry: 5,
|
|
||||||
retryDelay: (attempt) => attempt * 2500,
|
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
networkMode: 'always',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { data: data ?? placeholder, status, isFetching, isError, refetch };
|
return { data: data ?? placeholder, status, isFetching, isError, refetch };
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
|||||||
export function useCustomTranslation() {
|
export function useCustomTranslation() {
|
||||||
const { data, status, refetch } = useQuery({
|
const { data, status, refetch } = useQuery({
|
||||||
queryKey: TRANSLATION,
|
queryKey: TRANSLATION,
|
||||||
queryFn: getUserTranslation,
|
queryFn: ({ signal }) => getUserTranslation({ signal }),
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,12 +9,9 @@ import { ontimePlaceholderInfo } from '../models/Info';
|
|||||||
export default function useInfo() {
|
export default function useInfo() {
|
||||||
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
|
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
|
||||||
queryKey: APP_INFO,
|
queryKey: APP_INFO,
|
||||||
queryFn: getInfo,
|
queryFn: ({ signal }) => getInfo({ signal }),
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
retry: 5,
|
|
||||||
retryDelay: (attempt) => attempt * 2500,
|
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
networkMode: 'always',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { data: data ?? ontimePlaceholderInfo, status, isError, refetch, isFetching };
|
return { data: data ?? ontimePlaceholderInfo, status, isError, refetch, isFetching };
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { projectDataPlaceholder } from '../models/ProjectData';
|
|||||||
export default function useProjectData() {
|
export default function useProjectData() {
|
||||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||||
queryKey: PROJECT_DATA,
|
queryKey: PROJECT_DATA,
|
||||||
queryFn: getProjectData,
|
queryFn: ({ signal }) => getProjectData({ signal }),
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,12 +14,9 @@ const placeholderProjectList: ProjectFileListResponse = {
|
|||||||
function useProjectList() {
|
function useProjectList() {
|
||||||
const { data, status, refetch } = useQuery({
|
const { data, status, refetch } = useQuery({
|
||||||
queryKey: PROJECT_LIST,
|
queryKey: PROJECT_LIST,
|
||||||
queryFn: getProjects,
|
queryFn: ({ signal }) => getProjects({ signal }),
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
retry: 5,
|
|
||||||
retryDelay: (attempt: number) => attempt * 2500,
|
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
networkMode: 'always',
|
|
||||||
});
|
});
|
||||||
return { data: data ?? placeholderProjectList, status, refetch };
|
return { data: data ?? placeholderProjectList, status, refetch };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { createRundown, deleteRundown, duplicateRundown, fetchProjectRundownList
|
|||||||
export function useProjectRundowns() {
|
export function useProjectRundowns() {
|
||||||
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
|
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
|
||||||
queryKey: PROJECT_RUNDOWNS,
|
queryKey: PROJECT_RUNDOWNS,
|
||||||
queryFn: fetchProjectRundownList,
|
queryFn: ({ signal }) => fetchProjectRundownList({ signal }),
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,11 +8,8 @@ import { fetchReport } from '../api/report';
|
|||||||
export default function useReport() {
|
export default function useReport() {
|
||||||
const { data, refetch } = useQuery<OntimeReport>({
|
const { data, refetch } = useQuery<OntimeReport>({
|
||||||
queryKey: REPORT,
|
queryKey: REPORT,
|
||||||
queryFn: fetchReport,
|
queryFn: ({ signal }) => fetchReport({ signal }),
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
retry: 5,
|
|
||||||
retryDelay: (attempt) => attempt * 2500,
|
|
||||||
networkMode: 'always',
|
|
||||||
staleTime: MILLIS_PER_HOUR,
|
staleTime: MILLIS_PER_HOUR,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const cachedRundownPlaceholder: Rundown = {
|
|||||||
export default function useRundown() {
|
export default function useRundown() {
|
||||||
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
|
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
|
||||||
queryKey: RUNDOWN,
|
queryKey: RUNDOWN,
|
||||||
queryFn: fetchCurrentRundown,
|
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ontimePlaceholderSettings } from '../models/OntimeSettings';
|
|||||||
export default function useSettings() {
|
export default function useSettings() {
|
||||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||||
queryKey: APP_SETTINGS,
|
queryKey: APP_SETTINGS,
|
||||||
queryFn: getSettings,
|
queryFn: ({ signal }) => getSettings({ signal }),
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
select: (data) => {
|
select: (data) => {
|
||||||
const unobfuscated = { ...data };
|
const unobfuscated = { ...data };
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface FetchProps {
|
|||||||
export default function useUrlPresets({ skip = false }: FetchProps = {}) {
|
export default function useUrlPresets({ skip = false }: FetchProps = {}) {
|
||||||
const { data, status, isError, refetch } = useQuery({
|
const { data, status, isError, refetch } = useQuery({
|
||||||
queryKey: URL_PRESETS,
|
queryKey: URL_PRESETS,
|
||||||
queryFn: getUrlPresets,
|
queryFn: ({ signal }) => getUrlPresets({ signal }),
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
enabled: !skip,
|
enabled: !skip,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
|
|||||||
export default function useViewSettings() {
|
export default function useViewSettings() {
|
||||||
const { data, status } = useQuery({
|
const { data, status } = useQuery({
|
||||||
queryKey: VIEW_SETTINGS,
|
queryKey: VIEW_SETTINGS,
|
||||||
queryFn: getViewSettings,
|
queryFn: ({ signal }) => getViewSettings({ signal }),
|
||||||
placeholderData: (previousData, _previousQuery) => previousData,
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
staleTime: MILLIS_PER_HOUR,
|
staleTime: MILLIS_PER_HOUR,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,15 +6,31 @@ import {
|
|||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
isOntimeGroup,
|
isOntimeGroup,
|
||||||
MaybeString,
|
MaybeString,
|
||||||
|
OntimeDelay,
|
||||||
OntimeEntry,
|
OntimeEntry,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
|
OntimeGroup,
|
||||||
|
OntimeMilestone,
|
||||||
|
PatchWithId,
|
||||||
Rundown,
|
Rundown,
|
||||||
SupportedEntry,
|
SupportedEntry,
|
||||||
TimeField,
|
TimeField,
|
||||||
TimeStrategy,
|
TimeStrategy,
|
||||||
TransientEventPayload,
|
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, swapEventData } from 'ontime-utils';
|
import {
|
||||||
|
addToRundown,
|
||||||
|
createDelay,
|
||||||
|
createEvent,
|
||||||
|
createGroup,
|
||||||
|
createMilestone,
|
||||||
|
dayInMs,
|
||||||
|
generateId,
|
||||||
|
getInsertAfterId,
|
||||||
|
MILLIS_PER_SECOND,
|
||||||
|
parseUserTime,
|
||||||
|
resolveInsertParent,
|
||||||
|
swapEventData,
|
||||||
|
} from 'ontime-utils';
|
||||||
|
|
||||||
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
|
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
|
||||||
import { RUNDOWN } from '../api/constants';
|
import { RUNDOWN } from '../api/constants';
|
||||||
@@ -85,9 +101,64 @@ export const useEntryActions = () => {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
const { mutateAsync: addEntryMutation } = useMutation({
|
const { mutateAsync: addEntryMutation } = useMutation({
|
||||||
mutationFn: ([rundownId, entry]: Parameters<typeof postAddEntry>) => postAddEntry(rundownId, entry),
|
mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry),
|
||||||
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
onMutate: async ([_rundownId, entry]) => {
|
||||||
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
await queryClient.cancelQueries({ queryKey: RUNDOWN });
|
||||||
|
|
||||||
|
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||||
|
|
||||||
|
if (previousData) {
|
||||||
|
const optimisticEntry = createOptimisticEntry(entry);
|
||||||
|
const parentId = resolveInsertParent(previousData, entry);
|
||||||
|
const maybeParent = parentId ? previousData.entries[parentId] : null;
|
||||||
|
const parent = maybeParent && isOntimeGroup(maybeParent) ? maybeParent : null;
|
||||||
|
const afterId = getInsertAfterId(previousData, parent, entry.after, entry.before);
|
||||||
|
|
||||||
|
// create a mutable copy — addToRundown mutates in place using immutable array operations
|
||||||
|
const newRundown: Rundown = {
|
||||||
|
...previousData,
|
||||||
|
entries: { ...previousData.entries },
|
||||||
|
revision: -1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// copy the parent group so we don't mutate the original
|
||||||
|
if (parent && parentId) {
|
||||||
|
newRundown.entries[parentId] = { ...parent, entries: [...parent.entries] };
|
||||||
|
}
|
||||||
|
|
||||||
|
addToRundown(
|
||||||
|
newRundown,
|
||||||
|
optimisticEntry,
|
||||||
|
afterId,
|
||||||
|
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
queryClient.setQueryData<Rundown>(RUNDOWN, newRundown);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { previousData };
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
if (!response.data) return;
|
||||||
|
|
||||||
|
const serverEntry = response.data;
|
||||||
|
const currentData = queryClient.getQueryData<Rundown>(RUNDOWN);
|
||||||
|
|
||||||
|
if (currentData) {
|
||||||
|
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
||||||
|
...currentData,
|
||||||
|
entries: { ...currentData.entries, [serverEntry.id]: serverEntry },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (_error, _variables, context) => {
|
||||||
|
if (context?.previousData) {
|
||||||
|
queryClient.setQueryData<Rundown>(RUNDOWN, context.previousData);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -102,7 +173,15 @@ export const useEntryActions = () => {
|
|||||||
throw new Error('Rundown not initialised');
|
throw new Error('Rundown not initialised');
|
||||||
}
|
}
|
||||||
|
|
||||||
const newEntry: TransientEventPayload = { ...entry, id: generateId() };
|
const newEntry: PatchWithId & InsertOptions = { ...entry, id: generateId() };
|
||||||
|
|
||||||
|
// handle adding options that concern all event types
|
||||||
|
if (options?.after) {
|
||||||
|
newEntry.after = options.after;
|
||||||
|
}
|
||||||
|
if (options?.before) {
|
||||||
|
newEntry.before = options.before;
|
||||||
|
}
|
||||||
|
|
||||||
// ************* CHECK OPTIONS specific to events
|
// ************* CHECK OPTIONS specific to events
|
||||||
if (isOntimeEvent(newEntry)) {
|
if (isOntimeEvent(newEntry)) {
|
||||||
@@ -142,14 +221,6 @@ export const useEntryActions = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle adding options that concern all event type
|
|
||||||
if (options?.after) {
|
|
||||||
(newEntry as TransientEventPayload).after = options.after;
|
|
||||||
}
|
|
||||||
if (options?.before) {
|
|
||||||
(newEntry as TransientEventPayload).before = options.before;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addEntryMutation([rundownId, newEntry]);
|
await addEntryMutation([rundownId, newEntry]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -905,3 +976,29 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
|
|||||||
|
|
||||||
return { entries, order, flatOrder };
|
return { entries, order, flatOrder };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to create an optimistic entry for immediate cache insertion
|
||||||
|
*/
|
||||||
|
function createOptimisticEntry(payload: PatchWithId & InsertOptions): OntimeEntry {
|
||||||
|
const { after: _after, before: _before, ...entryData } = payload;
|
||||||
|
const id = entryData.id;
|
||||||
|
let parent: EntryId | null = null;
|
||||||
|
if ('parent' in entryData && entryData.parent) {
|
||||||
|
parent = entryData.parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (entryData.type) {
|
||||||
|
case SupportedEntry.Event: {
|
||||||
|
return createEvent({ ...entryData, id, parent }) as OntimeEvent;
|
||||||
|
}
|
||||||
|
case SupportedEntry.Delay:
|
||||||
|
return createDelay({ id, duration: (entryData as Partial<OntimeDelay>).duration ?? 0, parent });
|
||||||
|
case SupportedEntry.Group:
|
||||||
|
return createGroup({ ...(entryData as Partial<OntimeGroup>), id });
|
||||||
|
case SupportedEntry.Milestone:
|
||||||
|
return createMilestone({ ...(entryData as Partial<OntimeMilestone>), id, parent });
|
||||||
|
default:
|
||||||
|
throw new Error('Unknown entry type');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { QueryClient } from '@tanstack/react-query';
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
|
import axios from 'axios';
|
||||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||||
|
|
||||||
import { isOntimeCloud } from '../externals';
|
import { isOntimeCloud } from '../externals';
|
||||||
@@ -8,10 +9,15 @@ export const ontimeQueryClient = new QueryClient({
|
|||||||
queries: {
|
queries: {
|
||||||
gcTime: 10 * MILLIS_PER_MINUTE,
|
gcTime: 10 * MILLIS_PER_MINUTE,
|
||||||
// staleTime: MILLIS_PER_HOUR, //TODO: when all routes have implemented refetch signal from server, we can the assume that the data is not stale until we get the signal
|
// staleTime: MILLIS_PER_HOUR, //TODO: when all routes have implemented refetch signal from server, we can the assume that the data is not stale until we get the signal
|
||||||
networkMode: 'always',
|
networkMode: isOntimeCloud ? 'online' : 'always',
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
retry: 5,
|
retry: (failureCount, error) => {
|
||||||
retryDelay: (attempt) => attempt * 2500,
|
if (axios.isCancel(error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return failureCount < 5;
|
||||||
|
},
|
||||||
|
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 5000),
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
/**
|
/**
|
||||||
@@ -21,6 +27,7 @@ export const ontimeQueryClient = new QueryClient({
|
|||||||
* - use 'online' for clients that are connected to the cloud
|
* - use 'online' for clients that are connected to the cloud
|
||||||
*/
|
*/
|
||||||
networkMode: isOntimeCloud ? 'online' : 'always',
|
networkMode: isOntimeCloud ? 'online' : 'always',
|
||||||
|
retry: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,7 +38,13 @@ import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
|
|||||||
|
|
||||||
let websocket: WebSocket | null = null;
|
let websocket: WebSocket | null = null;
|
||||||
let reconnectTimeout: NodeJS.Timeout | null = null;
|
let reconnectTimeout: NodeJS.Timeout | null = null;
|
||||||
const reconnectInterval = 1000;
|
const socketConfig = {
|
||||||
|
reconnectBaseInterval: 1000, // 1 second
|
||||||
|
reconnectMaxInterval: 30000, // 30 seconds
|
||||||
|
reconnectMinInterval: 500, // 0.5 seconds
|
||||||
|
reconnectJitter: 0.25,
|
||||||
|
offlineAttemptsThreshold: 2, // when we consider the client disconnected
|
||||||
|
} as const;
|
||||||
|
|
||||||
export let hasConnected = false;
|
export let hasConnected = false;
|
||||||
export let reconnectAttempts = 0;
|
export let reconnectAttempts = 0;
|
||||||
@@ -49,7 +55,11 @@ export const connectSocket = () => {
|
|||||||
const preferredClientName = getClientName();
|
const preferredClientName = getClientName();
|
||||||
|
|
||||||
websocket.onopen = () => {
|
websocket.onopen = () => {
|
||||||
clearTimeout(reconnectTimeout as NodeJS.Timeout);
|
const isReconnect = hasConnected;
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
reconnectTimeout = null;
|
||||||
|
}
|
||||||
hasConnected = true;
|
hasConnected = true;
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
|
|
||||||
@@ -59,24 +69,38 @@ export const connectSocket = () => {
|
|||||||
path: window.location.pathname + window.location.search,
|
path: window.location.pathname + window.location.search,
|
||||||
name: preferredClientName,
|
name: preferredClientName,
|
||||||
});
|
});
|
||||||
invalidateAllCaches(); // assume all data to be stale after a reconnect
|
|
||||||
|
if (isReconnect) {
|
||||||
|
invalidateAllCaches();
|
||||||
|
}
|
||||||
setOnlineStatus(true);
|
setOnlineStatus(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
websocket.onclose = () => {
|
websocket.onclose = () => {
|
||||||
console.warn('WebSocket disconnected');
|
console.warn('WebSocket disconnected');
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
reconnectTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exponentialDelay = Math.min(
|
||||||
|
socketConfig.reconnectBaseInterval * 2 ** reconnectAttempts,
|
||||||
|
socketConfig.reconnectMaxInterval,
|
||||||
|
);
|
||||||
|
const jitterOffset = exponentialDelay * socketConfig.reconnectJitter * (Math.random() * 2 - 1);
|
||||||
|
const delay = Math.max(socketConfig.reconnectMinInterval, Math.round(exponentialDelay + jitterOffset));
|
||||||
|
|
||||||
// we decide to allows reconnect
|
|
||||||
reconnectTimeout = setTimeout(() => {
|
reconnectTimeout = setTimeout(() => {
|
||||||
if (reconnectAttempts > 2) {
|
reconnectTimeout = null;
|
||||||
|
if (reconnectAttempts > socketConfig.offlineAttemptsThreshold) {
|
||||||
setOnlineStatus(false);
|
setOnlineStatus(false);
|
||||||
}
|
}
|
||||||
console.warn('WebSocket: attempting reconnect');
|
console.warn(`WebSocket: reconnecting now (#${reconnectAttempts + 1}, waited ${delay}ms)`);
|
||||||
if (websocket && websocket.readyState === WebSocket.CLOSED) {
|
if (websocket && websocket.readyState === WebSocket.CLOSED) {
|
||||||
reconnectAttempts += 1;
|
reconnectAttempts += 1;
|
||||||
connectSocket();
|
connectSocket();
|
||||||
}
|
}
|
||||||
}, reconnectInterval);
|
}, delay);
|
||||||
};
|
};
|
||||||
|
|
||||||
websocket.onerror = (error) => {
|
websocket.onerror = (error) => {
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export default function GeneralSettings() {
|
|||||||
} = useForm<Settings>({
|
} = useForm<Settings>({
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
defaultValues: data,
|
defaultValues: data,
|
||||||
values: data,
|
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDirtyValues: true,
|
keepDirtyValues: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export default function ViewSettings() {
|
|||||||
formState: { isSubmitting, isDirty, errors },
|
formState: { isSubmitting, isDirty, errors },
|
||||||
} = useForm<ViewSettingsType>({
|
} = useForm<ViewSettingsType>({
|
||||||
defaultValues: data,
|
defaultValues: data,
|
||||||
values: data,
|
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDirtyValues: true,
|
keepDirtyValues: true,
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-2
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, useEffect, useRef, useState } from 'react';
|
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../../common/api/assets';
|
import { getCSSContents, postCSSContents, restoreCSSContents } from '../../../../../common/api/assets';
|
||||||
import Button from '../../../../../common/components/buttons/Button';
|
import Button from '../../../../../common/components/buttons/Button';
|
||||||
@@ -81,7 +81,9 @@ export default function CodeEditorModal({ isOpen, onClose }: CodeEditorModalProp
|
|||||||
showCloseButton
|
showCloseButton
|
||||||
showBackdrop
|
showBackdrop
|
||||||
bodyElements={
|
bodyElements={
|
||||||
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
|
<Suspense fallback={null}>
|
||||||
|
<CodeEditor ref={cssRef} initialValue={css} language='css' isDirty={isDirty} setIsDirty={setIsDirty} />
|
||||||
|
</Suspense>
|
||||||
}
|
}
|
||||||
footerElements={
|
footerElements={
|
||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type HTMLProps, forwardRef, Fragment, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { type HTMLProps, forwardRef, Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { TbFlagFilled } from 'react-icons/tb';
|
import { TbFlagFilled } from 'react-icons/tb';
|
||||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||||
import { closestCenter, DndContext } from '@dnd-kit/core';
|
import { closestCenter, DndContext } from '@dnd-kit/core';
|
||||||
@@ -26,14 +26,13 @@ import RundownGroup from './rundown-group/RundownGroup';
|
|||||||
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
|
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
|
||||||
import { filterVisibleEntries, makeSortableList } from './rundown.utils';
|
import { filterVisibleEntries, makeSortableList } from './rundown.utils';
|
||||||
import RundownEmpty from './RundownEmpty';
|
import RundownEmpty from './RundownEmpty';
|
||||||
|
import RundownEntry from './RundownEntry';
|
||||||
import { useCollapsedGroups } from './useCollapsedGroups';
|
import { useCollapsedGroups } from './useCollapsedGroups';
|
||||||
import { useEditorFollowMode } from './useEditorFollowMode';
|
import { useEditorFollowMode } from './useEditorFollowMode';
|
||||||
import { useEventSelection } from './useEventSelection';
|
import { useEventSelection } from './useEventSelection';
|
||||||
|
|
||||||
import style from './Rundown.module.scss';
|
import style from './Rundown.module.scss';
|
||||||
|
|
||||||
const RundownEntry = lazy(() => import('./RundownEntry'));
|
|
||||||
|
|
||||||
interface RundownProps {
|
interface RundownProps {
|
||||||
entries: RundownType['entries'];
|
entries: RundownType['entries'];
|
||||||
id: RundownType['id'];
|
id: RundownType['id'];
|
||||||
|
|||||||
@@ -181,10 +181,10 @@ export function getCardData(
|
|||||||
: getPropertyValue(eventNow, secondarySource, entries);
|
: getPropertyValue(eventNow, secondarySource, entries);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
showNow: mainSource !== 'none' || Boolean(nowSecondary),
|
showNow: mainSource !== 'none' && (Boolean(nowMain) || Boolean(nowSecondary)),
|
||||||
nowMain,
|
nowMain,
|
||||||
nowSecondary,
|
nowSecondary,
|
||||||
showNext: mainSource !== 'none' || Boolean(nextSecondary),
|
showNext: mainSource !== 'none' && (Boolean(nextMain) || Boolean(nextSecondary)),
|
||||||
nextMain,
|
nextMain,
|
||||||
nextSecondary,
|
nextSecondary,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-electron",
|
"name": "ontime-electron",
|
||||||
"version": "4.4.0",
|
"version": "4.4.2",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/resolver",
|
"name": "@getontime/resolver",
|
||||||
"version": "4.4.0",
|
"version": "4.4.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
"types": "./dist/main.d.ts",
|
"types": "./dist/main.d.ts",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "4.4.0",
|
"version": "4.4.2",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
|
|||||||
@@ -17,10 +17,15 @@ import {
|
|||||||
URLPreset,
|
URLPreset,
|
||||||
ViewSettings,
|
ViewSettings,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { customFieldLabelToKey, checkRegex, isKnownTimerType, validateEndAction } from 'ontime-utils';
|
import {
|
||||||
|
customFieldLabelToKey,
|
||||||
|
checkRegex,
|
||||||
|
isKnownTimerType,
|
||||||
|
validateEndAction,
|
||||||
|
eventDef as eventModel,
|
||||||
|
} from 'ontime-utils';
|
||||||
|
|
||||||
import { is } from '../../../utils/is.js';
|
import { is } from '../../../utils/is.js';
|
||||||
import { event as eventModel } from '../../../models/eventsDefinition.js';
|
|
||||||
import { ONTIME_VERSION } from '../../../ONTIME_VERSION.js';
|
import { ONTIME_VERSION } from '../../../ONTIME_VERSION.js';
|
||||||
import { getPartialProject } from '../../../models/dataModel.js';
|
import { getPartialProject } from '../../../models/dataModel.js';
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ import {
|
|||||||
isKnownTimerType,
|
isKnownTimerType,
|
||||||
validateTimerType,
|
validateTimerType,
|
||||||
validateEndAction,
|
validateEndAction,
|
||||||
|
makeString,
|
||||||
} from 'ontime-utils';
|
} from 'ontime-utils';
|
||||||
|
|
||||||
import { Prettify } from 'ts-essentials';
|
import { Prettify } from 'ts-essentials';
|
||||||
|
|
||||||
import { is } from '../../utils/is.js';
|
import { is } from '../../utils/is.js';
|
||||||
import { makeString } from '../../utils/parserUtils.js';
|
|
||||||
import { parseExcelDate } from '../../utils/time.js';
|
import { parseExcelDate } from '../../utils/time.js';
|
||||||
import { generateImportHandlers, getCustomFieldData, parseBooleanString, SheetMetadata } from './excel.utils.js';
|
import { generateImportHandlers, getCustomFieldData, parseBooleanString, SheetMetadata } from './excel.utils.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TimeStrategy, EndAction, TimerType, OntimeEvent, OntimeGroup } from 'ontime-types';
|
import { TimeStrategy, EndAction, TimerType, OntimeEvent } from 'ontime-types';
|
||||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
import { createEvent, MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
|
|
||||||
import { assertType } from 'vitest';
|
import { assertType } from 'vitest';
|
||||||
|
|
||||||
@@ -7,11 +7,9 @@ import { demoDb } from '../../../models/demoProject.js';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
calculateDayOffset,
|
calculateDayOffset,
|
||||||
createEvent,
|
|
||||||
deleteById,
|
deleteById,
|
||||||
doesInvalidateMetadata,
|
doesInvalidateMetadata,
|
||||||
duplicateRundown,
|
duplicateRundown,
|
||||||
getInsertAfterId,
|
|
||||||
hasChanges,
|
hasChanges,
|
||||||
makeDeepClone,
|
makeDeepClone,
|
||||||
} from '../rundown.utils.js';
|
} from '../rundown.utils.js';
|
||||||
@@ -226,45 +224,6 @@ describe('calculateDayOffset()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getInsertAfterId()', () => {
|
|
||||||
const rundown = makeRundown({
|
|
||||||
entries: {
|
|
||||||
'1': makeOntimeEvent({ id: '1', parent: null }),
|
|
||||||
'2': makeOntimeEvent({ id: '2', parent: null }),
|
|
||||||
group: makeOntimeGroup({ id: 'group', entries: ['31', '32'] }),
|
|
||||||
'31': makeOntimeEvent({ id: '31', parent: 'group' }),
|
|
||||||
'32': makeOntimeEvent({ id: '32', parent: 'group' }),
|
|
||||||
'4': makeOntimeEvent({ id: '4', parent: null }),
|
|
||||||
},
|
|
||||||
order: ['1', '2', 'group', '4'],
|
|
||||||
flatOrder: ['1', '2', 'group', '31', '32', '4'],
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns afterId if provided', () => {
|
|
||||||
expect(getInsertAfterId(rundown, null, 'b')).toBe('b');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null if neither afterId nor beforeId is provided', () => {
|
|
||||||
expect(getInsertAfterId(rundown, null)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null if beforeId is not found', () => {
|
|
||||||
expect(getInsertAfterId(rundown, null, undefined, 'z')).toBeNull();
|
|
||||||
expect(getInsertAfterId(rundown, null, undefined, '1')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the previous id of an entry in the rundown', () => {
|
|
||||||
expect(getInsertAfterId(rundown, null, undefined, '2')).toBe('1');
|
|
||||||
expect(getInsertAfterId(rundown, null, undefined, '4')).toBe('group');
|
|
||||||
expect(getInsertAfterId(rundown, null, undefined, 'group')).toBe('2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the previous id of an event in a group', () => {
|
|
||||||
expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '31')).toBeNull();
|
|
||||||
expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '32')).toBe('31');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('duplicateRundown', () => {
|
describe('duplicateRundown', () => {
|
||||||
it('duplicates a given rundown', () => {
|
it('duplicates a given rundown', () => {
|
||||||
const demoRundown = demoDb.rundowns['default'];
|
const demoRundown = demoDb.rundowns['default'];
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
Rundown,
|
Rundown,
|
||||||
InsertOptions,
|
InsertOptions,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { customFieldLabelToKey, insertAtIndex } from 'ontime-utils';
|
import { addToRundown, customFieldLabelToKey, getInsertAfterId, insertAtIndex, createGroup } from 'ontime-utils';
|
||||||
|
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
import { consoleError } from '../../utils/console.js';
|
import { consoleError } from '../../utils/console.js';
|
||||||
@@ -35,10 +35,8 @@ import type { RundownMetadata } from './rundown.types.js';
|
|||||||
import {
|
import {
|
||||||
applyPatchToEntry,
|
applyPatchToEntry,
|
||||||
cloneSimpleRundownEntry,
|
cloneSimpleRundownEntry,
|
||||||
createGroup,
|
|
||||||
deleteById,
|
deleteById,
|
||||||
doesInvalidateMetadata,
|
doesInvalidateMetadata,
|
||||||
getInsertAfterId,
|
|
||||||
getUniqueId,
|
getUniqueId,
|
||||||
makeDeepClone,
|
makeDeepClone,
|
||||||
} from './rundown.utils.js';
|
} from './rundown.utils.js';
|
||||||
@@ -110,11 +108,6 @@ export function createTransaction(options: TransactionOptions): Transaction {
|
|||||||
function commit(shouldProcess: boolean = true) {
|
function commit(shouldProcess: boolean = true) {
|
||||||
// if the rundown is mutable we persist the changes
|
// if the rundown is mutable we persist the changes
|
||||||
if (options.mutableRundown) {
|
if (options.mutableRundown) {
|
||||||
// schedule a database update
|
|
||||||
setImmediate(async () => {
|
|
||||||
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
|
|
||||||
});
|
|
||||||
|
|
||||||
// update fields which are agnostic of whether the rundown is processed
|
// update fields which are agnostic of whether the rundown is processed
|
||||||
cachedRundown.revision = cachedRundown.revision + 1;
|
cachedRundown.revision = cachedRundown.revision + 1;
|
||||||
cachedRundown.title = rundown.title;
|
cachedRundown.title = rundown.title;
|
||||||
@@ -135,16 +128,17 @@ export function createTransaction(options: TransactionOptions): Transaction {
|
|||||||
cachedRundown.flatOrder = metadata.flatEntryOrder;
|
cachedRundown.flatOrder = metadata.flatEntryOrder;
|
||||||
rundownMetadata = metadata;
|
rundownMetadata = metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// persist after all mutations are applied
|
||||||
|
getDataProvider().setRundown(cachedRundown.id, cachedRundown);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the customFields are mutable we persist the changes
|
// if the customFields are mutable we persist the changes
|
||||||
if (options.mutableCustomFields) {
|
if (options.mutableCustomFields) {
|
||||||
// schedule a database update
|
|
||||||
setImmediate(async () => {
|
|
||||||
await getDataProvider().setCustomFields(projectCustomFields);
|
|
||||||
});
|
|
||||||
|
|
||||||
projectCustomFields = customFields;
|
projectCustomFields = customFields;
|
||||||
|
|
||||||
|
// persist after reassignment
|
||||||
|
getDataProvider().setCustomFields(projectCustomFields);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -163,50 +157,6 @@ export function createTransaction(options: TransactionOptions): Transaction {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Add entry to rundown, handles the following cases:
|
|
||||||
* - 1a. add entry in group, after a given entry
|
|
||||||
* - 1b. add entry in group, at the beginning
|
|
||||||
* - 2a. add entry to the rundown, after a given entry
|
|
||||||
* - 2b. add entry to the rundown, at the beginning
|
|
||||||
*/
|
|
||||||
function add(rundown: Rundown, entry: OntimeEntry, afterId: EntryId | null, parent: OntimeGroup | null): OntimeEntry {
|
|
||||||
if (parent) {
|
|
||||||
// 1. inserting an entry inside a group
|
|
||||||
|
|
||||||
if ('parent' in entry) {
|
|
||||||
entry.parent = parent.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (afterId) {
|
|
||||||
const atEventsIndex = parent.entries.indexOf(afterId) + 1;
|
|
||||||
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
|
|
||||||
parent.entries = insertAtIndex(atEventsIndex, entry.id, parent.entries);
|
|
||||||
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
|
||||||
} else {
|
|
||||||
parent.entries = insertAtIndex(0, entry.id, parent.entries);
|
|
||||||
const atFlatIndex = rundown.flatOrder.indexOf(parent.id) + 1;
|
|
||||||
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 2. inserting an entry at top level
|
|
||||||
if (afterId) {
|
|
||||||
const atOrderIndex = rundown.order.indexOf(afterId) + 1;
|
|
||||||
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
|
|
||||||
rundown.order = insertAtIndex(atOrderIndex, entry.id, rundown.order);
|
|
||||||
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
|
||||||
} else {
|
|
||||||
rundown.order = insertAtIndex(0, entry.id, rundown.order);
|
|
||||||
rundown.flatOrder = insertAtIndex(0, entry.id, rundown.flatOrder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// either way, we insert the entry into the rundown
|
|
||||||
rundown.entries[entry.id] = entry;
|
|
||||||
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies a patch of changes to an existing entry
|
* Applies a patch of changes to an existing entry
|
||||||
* @returns { entry: OntimeEntry, didInvalidate: boolean } - didInvalidate indicates whether the change warrants a recalculation of the cache
|
* @returns { entry: OntimeEntry, didInvalidate: boolean } - didInvalidate indicates whether the change warrants a recalculation of the cache
|
||||||
@@ -516,7 +466,7 @@ function clone(rundown: Rundown, entry: OntimeEntry, options?: InsertOptions): O
|
|||||||
after = entry.id;
|
after = entry.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return add(rundown, clonedEntry, after, parent);
|
return addToRundown(rundown, clonedEntry, after, parent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,7 +533,7 @@ function ungroup(rundown: Rundown, group: OntimeGroup) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const rundownMutation = {
|
export const rundownMutation = {
|
||||||
add,
|
add: addToRundown,
|
||||||
edit,
|
edit,
|
||||||
remove,
|
remove,
|
||||||
removeAll,
|
removeAll,
|
||||||
@@ -598,10 +548,8 @@ export const rundownMutation = {
|
|||||||
/**
|
/**
|
||||||
* Exposes a way to update a rundown which is not active
|
* Exposes a way to update a rundown which is not active
|
||||||
*/
|
*/
|
||||||
export function updateBackgroundRundown(rundownId: string, rundown: Rundown) {
|
export async function updateBackgroundRundown(rundownId: string, rundown: Rundown) {
|
||||||
setImmediate(async () => {
|
await getDataProvider().setRundown(rundownId, rundown);
|
||||||
await getDataProvider().setRundown(rundownId, rundown);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -698,9 +646,7 @@ export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Rea
|
|||||||
rundownMetadata = metadata;
|
rundownMetadata = metadata;
|
||||||
|
|
||||||
// defer writing to the database
|
// defer writing to the database
|
||||||
setImmediate(async () => {
|
getDataProvider().setRundown(cachedRundown.id, cachedRundown);
|
||||||
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
|
|
||||||
});
|
|
||||||
|
|
||||||
return { rundown, rundownMetadata, customFields, revision: rundown.revision };
|
return { rundown, rundownMetadata, customFields, revision: rundown.revision };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,22 @@ import {
|
|||||||
OntimeMilestone,
|
OntimeMilestone,
|
||||||
OntimeGroup,
|
OntimeGroup,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
|
import {
|
||||||
|
isObjectEmpty,
|
||||||
|
generateId,
|
||||||
|
getLinkedTimes,
|
||||||
|
getTimeFrom,
|
||||||
|
isNewLatest,
|
||||||
|
createDelay,
|
||||||
|
createEvent,
|
||||||
|
createGroup,
|
||||||
|
createMilestone,
|
||||||
|
} from 'ontime-utils';
|
||||||
|
|
||||||
import { delay as delayDef } from '../../models/eventsDefinition.js';
|
|
||||||
import { makeNewRundown } from '../../models/dataModel.js';
|
import { makeNewRundown } from '../../models/dataModel.js';
|
||||||
import type { ErrorEmitter } from '../../utils/parserUtils.js';
|
import type { ErrorEmitter } from '../../utils/parserUtils.js';
|
||||||
|
|
||||||
import { calculateDayOffset, cleanupCustomFields, createGroup, createEvent, createMilestone } from './rundown.utils.js';
|
import { calculateDayOffset, cleanupCustomFields } from './rundown.utils.js';
|
||||||
import { RundownMetadata } from './rundown.types.js';
|
import { RundownMetadata } from './rundown.types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,7 +116,7 @@ export function parseRundown(
|
|||||||
cleanupCustomFields(newEvent.custom, parsedCustomFields);
|
cleanupCustomFields(newEvent.custom, parsedCustomFields);
|
||||||
eventIndex += 1;
|
eventIndex += 1;
|
||||||
} else if (isOntimeDelay(event)) {
|
} else if (isOntimeDelay(event)) {
|
||||||
newEvent = { ...delayDef, duration: event.duration, id };
|
newEvent = createDelay({ duration: event.duration, id });
|
||||||
} else if (isOntimeMilestone(event)) {
|
} else if (isOntimeMilestone(event)) {
|
||||||
newEvent = createMilestone({ ...event, id });
|
newEvent = createMilestone({ ...event, id });
|
||||||
cleanupCustomFields(newEvent.custom, parsedCustomFields);
|
cleanupCustomFields(newEvent.custom, parsedCustomFields);
|
||||||
@@ -134,7 +143,7 @@ export function parseRundown(
|
|||||||
cleanupCustomFields(newNestedEvent.custom, parsedCustomFields);
|
cleanupCustomFields(newNestedEvent.custom, parsedCustomFields);
|
||||||
eventIndex += 1;
|
eventIndex += 1;
|
||||||
} else if (isOntimeDelay(nestedEvent)) {
|
} else if (isOntimeDelay(nestedEvent)) {
|
||||||
newNestedEvent = { ...delayDef, duration: nestedEvent.duration, id: nestedEventId };
|
newNestedEvent = createDelay({ duration: nestedEvent.duration, id: nestedEventId });
|
||||||
newNestedEvent.parent = event.id;
|
newNestedEvent.parent = event.id;
|
||||||
} else if (isOntimeMilestone(nestedEvent)) {
|
} else if (isOntimeMilestone(nestedEvent)) {
|
||||||
newNestedEvent = createMilestone({ ...nestedEvent, id: nestedEventId });
|
newNestedEvent = createMilestone({ ...nestedEvent, id: nestedEventId });
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
ProjectRundowns,
|
ProjectRundowns,
|
||||||
InsertOptions,
|
InsertOptions,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { customFieldLabelToKey } from 'ontime-utils';
|
import { customFieldLabelToKey, getInsertAfterId, resolveInsertParent } from 'ontime-utils';
|
||||||
|
|
||||||
import { updateRundownData } from '../../stores/runtimeState.js';
|
import { updateRundownData } from '../../stores/runtimeState.js';
|
||||||
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
|
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
|
||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
updateBackgroundRundown,
|
updateBackgroundRundown,
|
||||||
} from './rundown.dao.js';
|
} from './rundown.dao.js';
|
||||||
import type { RundownMetadata } from './rundown.types.js';
|
import type { RundownMetadata } from './rundown.types.js';
|
||||||
import { generateEvent, getInsertAfterId, hasChanges } from './rundown.utils.js';
|
import { generateEvent, hasChanges } from './rundown.utils.js';
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,28 +47,15 @@ export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry
|
|||||||
throw new Error(`Event with ID ${eventData.id} already exists`);
|
throw new Error(`Event with ID ${eventData.id} already exists`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// the parent can be provided or inferred from position
|
// resolve the parent, either from the payload or inferred from sibling position
|
||||||
|
const parentId = resolveInsertParent(rundown, eventData);
|
||||||
let parent: OntimeGroup | null = null;
|
let parent: OntimeGroup | null = null;
|
||||||
|
if (parentId) {
|
||||||
if ('parent' in eventData && eventData.parent != null) {
|
const maybeParent = rundown.entries[parentId];
|
||||||
// if the user provides a parent (inside a group), we make sure it exists and it is a group
|
|
||||||
const maybeParent = rundown.entries[eventData.parent];
|
|
||||||
if (!maybeParent || !isOntimeGroup(maybeParent)) {
|
if (!maybeParent || !isOntimeGroup(maybeParent)) {
|
||||||
throw new Error(`Invalid parent event with ID ${eventData.parent}`);
|
throw new Error(`Invalid parent event with ID ${parentId}`);
|
||||||
}
|
}
|
||||||
parent = maybeParent;
|
parent = maybeParent;
|
||||||
} else {
|
|
||||||
// otherwise, we may infer the parent from relative positioning (after/before)
|
|
||||||
const referenceId = eventData?.after ?? eventData?.before;
|
|
||||||
if (referenceId) {
|
|
||||||
const maybeSibling = rundown.entries[referenceId];
|
|
||||||
if (maybeSibling && 'parent' in maybeSibling && maybeSibling.parent) {
|
|
||||||
const maybeParent = rundown.entries[maybeSibling.parent];
|
|
||||||
if (maybeParent && isOntimeGroup(maybeParent)) {
|
|
||||||
parent = maybeParent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalise the position of the event in the rundown order
|
// normalise the position of the event in the rundown order
|
||||||
@@ -499,7 +486,7 @@ export async function editCustomField(
|
|||||||
if (rundownId !== rundown.id) {
|
if (rundownId !== rundown.id) {
|
||||||
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
|
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
|
||||||
customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey);
|
customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey);
|
||||||
updateBackgroundRundown(rundownId, backgroundRundown);
|
await updateBackgroundRundown(rundownId, backgroundRundown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,7 +526,7 @@ export async function deleteCustomField(key: CustomFieldKey, projectRundowns: Pr
|
|||||||
if (rundownId !== rundown.id) {
|
if (rundownId !== rundown.id) {
|
||||||
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
|
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
|
||||||
customFieldMutation.removeUsages(backgroundRundown, key);
|
customFieldMutation.removeUsages(backgroundRundown, key);
|
||||||
updateBackgroundRundown(rundownId, backgroundRundown);
|
await updateBackgroundRundown(rundownId, backgroundRundown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,19 +22,16 @@ import {
|
|||||||
dayInMs,
|
dayInMs,
|
||||||
generateId,
|
generateId,
|
||||||
getCueCandidate,
|
getCueCandidate,
|
||||||
|
createDelay,
|
||||||
|
createEvent,
|
||||||
|
createGroup,
|
||||||
|
createMilestone,
|
||||||
|
makeString,
|
||||||
validateEndAction,
|
validateEndAction,
|
||||||
validateTimerType,
|
validateTimerType,
|
||||||
validateTimes,
|
validateTimes,
|
||||||
} from 'ontime-utils';
|
} from 'ontime-utils';
|
||||||
|
|
||||||
import {
|
|
||||||
event as eventDef,
|
|
||||||
group as groupDef,
|
|
||||||
delay as delayDef,
|
|
||||||
milestone as milestoneDef,
|
|
||||||
} from '../../models/eventsDefinition.js';
|
|
||||||
import { makeString } from '../../utils/parserUtils.js';
|
|
||||||
|
|
||||||
import { RundownMetadata } from './rundown.types.js';
|
import { RundownMetadata } from './rundown.types.js';
|
||||||
|
|
||||||
type CompleteEntry<T> =
|
type CompleteEntry<T> =
|
||||||
@@ -61,7 +58,7 @@ export function generateEvent<
|
|||||||
const id = eventData.id || getUniqueId(rundown);
|
const id = eventData.id || getUniqueId(rundown);
|
||||||
|
|
||||||
if (isOntimeDelay(eventData)) {
|
if (isOntimeDelay(eventData)) {
|
||||||
return { ...delayDef, duration: eventData.duration ?? 0, id } as CompleteEntry<T>;
|
return createDelay({ duration: eventData.duration ?? 0, id }) as CompleteEntry<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(v4): allow user to provide a larger patch of the group entry
|
// TODO(v4): allow user to provide a larger patch of the group entry
|
||||||
@@ -198,74 +195,6 @@ export function applyPatchToEntry(eventFromRundown: OntimeEntry, patch: Partial<
|
|||||||
return { ...eventFromRundown, ...patch } as OntimeDelay;
|
return { ...eventFromRundown, ...patch } as OntimeDelay;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Enforces formatting for events
|
|
||||||
* @param {object} eventArgs - attributes of event
|
|
||||||
* @param {number} eventIndex - can be a string when we pass the a suggested cue name
|
|
||||||
* @returns {object|null} - formatted object or null in case is invalid
|
|
||||||
*/
|
|
||||||
export const createEvent = (eventArgs: Partial<OntimeEvent>, eventIndex: number | string): OntimeEvent | null => {
|
|
||||||
if (Object.keys(eventArgs).length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cue = typeof eventIndex === 'number' ? String(eventIndex + 1) : eventIndex;
|
|
||||||
|
|
||||||
const baseEvent = {
|
|
||||||
id: eventArgs?.id ?? generateId(),
|
|
||||||
cue,
|
|
||||||
...eventDef,
|
|
||||||
};
|
|
||||||
const event = createEventPatch(baseEvent, eventArgs);
|
|
||||||
return event;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new group from an optional patch
|
|
||||||
*/
|
|
||||||
export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
|
|
||||||
if (!patch) {
|
|
||||||
return { ...groupDef, id: generateId() };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: patch.id ?? generateId(),
|
|
||||||
type: SupportedEntry.Group,
|
|
||||||
title: patch.title ?? '',
|
|
||||||
note: patch.note ?? '',
|
|
||||||
entries: patch.entries ?? [],
|
|
||||||
targetDuration: patch.targetDuration ?? null,
|
|
||||||
colour: makeString(patch.colour, ''),
|
|
||||||
custom: patch.custom ?? {},
|
|
||||||
revision: 0,
|
|
||||||
timeStart: null,
|
|
||||||
timeEnd: null,
|
|
||||||
duration: 0,
|
|
||||||
isFirstLinked: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new milestone from an optional patch
|
|
||||||
*/
|
|
||||||
export function createMilestone(patch?: Partial<OntimeMilestone>): OntimeMilestone {
|
|
||||||
if (!patch) {
|
|
||||||
return { ...milestoneDef, id: generateId() };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: patch.id ?? generateId(),
|
|
||||||
type: SupportedEntry.Milestone,
|
|
||||||
cue: patch.cue ?? '',
|
|
||||||
title: patch.title ?? '',
|
|
||||||
note: patch.note ?? '',
|
|
||||||
colour: makeString(patch.colour, ''),
|
|
||||||
custom: patch.custom ?? {},
|
|
||||||
parent: patch.parent ?? null,
|
|
||||||
revision: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function infers strategy for a patch with only partial timer data
|
* Function infers strategy for a patch with only partial timer data
|
||||||
* @param end
|
* @param end
|
||||||
@@ -483,31 +412,6 @@ export function calculateDayOffset(
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Receives an insertion order and returns the reference to an event ID
|
|
||||||
* after which we will insert the new event
|
|
||||||
*/
|
|
||||||
export function getInsertAfterId(
|
|
||||||
rundown: Rundown,
|
|
||||||
parent: OntimeGroup | null,
|
|
||||||
afterId?: EntryId,
|
|
||||||
beforeId?: EntryId,
|
|
||||||
): EntryId | null {
|
|
||||||
if (afterId) return afterId;
|
|
||||||
if (!beforeId) return null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* At this point we know we want to insert before a given ID
|
|
||||||
* We need to check which list we should use to insert and find the event there
|
|
||||||
*/
|
|
||||||
const insertionList = parent ? parent.entries : rundown.order;
|
|
||||||
if (!insertionList || insertionList.length === 0) return null;
|
|
||||||
|
|
||||||
const atIndex = insertionList.findIndex((id) => id === beforeId);
|
|
||||||
if (atIndex < 1) return null;
|
|
||||||
return insertionList[atIndex - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitises custom fields in an entry by removing fields
|
* Sanitises custom fields in an entry by removing fields
|
||||||
* - if it does not exist in the project
|
* - if it does not exist in the project
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { integrationRouter } from './api-integration/integration.router.js';
|
|||||||
|
|
||||||
// Import adapters
|
// Import adapters
|
||||||
import { socket } from './adapters/WebsocketAdapter.js';
|
import { socket } from './adapters/WebsocketAdapter.js';
|
||||||
import { getDataProvider } from './classes/data-provider/DataProvider.js';
|
import { getDataProvider, flushPendingWrites } from './classes/data-provider/DataProvider.js';
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
import { logger } from './classes/Logger.js';
|
import { logger } from './classes/Logger.js';
|
||||||
@@ -266,6 +266,10 @@ export const startIntegrations = async () => {
|
|||||||
export const shutdown = async (exitCode = 0) => {
|
export const shutdown = async (exitCode = 0) => {
|
||||||
consoleHighlight(`Ontime shutting down with code ${exitCode}`);
|
consoleHighlight(`Ontime shutting down with code ${exitCode}`);
|
||||||
|
|
||||||
|
await flushPendingWrites().catch((_error) => {
|
||||||
|
/** nothing do to here */
|
||||||
|
});
|
||||||
|
|
||||||
// clear the restore file if it was a normal exit
|
// clear the restore file if it was a normal exit
|
||||||
// 0 means it was a SIGNAL
|
// 0 means it was a SIGNAL
|
||||||
// 1 means crash -> keep the file
|
// 1 means crash -> keep the file
|
||||||
|
|||||||
@@ -180,10 +180,58 @@ async function mergeIntoData(newData: Partial<DatabaseModel>): ReadonlyPromise<D
|
|||||||
return db.data;
|
return db.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Module-level state for debounced writes
|
||||||
|
let pendingWrite: NodeJS.Timeout | null = null;
|
||||||
|
let activeWrite: Promise<void> | null = null;
|
||||||
|
const writeDelayMs = 3000; // 3 seconds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles persisting data to file
|
* Handles persisting data to file with trailing-edge debounce
|
||||||
|
* Multiple rapid calls will be coalesced into a single write
|
||||||
*/
|
*/
|
||||||
async function persist() {
|
async function persist() {
|
||||||
if (isTest) return;
|
if (isTest) return;
|
||||||
|
|
||||||
|
// Cancel any pending write and reschedule
|
||||||
|
if (pendingWrite) {
|
||||||
|
clearTimeout(pendingWrite);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule new write after quiet period
|
||||||
|
pendingWrite = setTimeout(async () => {
|
||||||
|
pendingWrite = null;
|
||||||
|
|
||||||
|
// Wait for any in-progress write to finish first
|
||||||
|
if (activeWrite) {
|
||||||
|
await activeWrite;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
activeWrite = db.write();
|
||||||
|
await activeWrite;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to persist database:', error);
|
||||||
|
} finally {
|
||||||
|
activeWrite = null;
|
||||||
|
}
|
||||||
|
}, writeDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force immediate write of any pending changes
|
||||||
|
*/
|
||||||
|
export async function flushPendingWrites() {
|
||||||
|
if (isTest) return;
|
||||||
|
|
||||||
|
if (pendingWrite) {
|
||||||
|
clearTimeout(pendingWrite);
|
||||||
|
pendingWrite = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for any in-progress write to finish
|
||||||
|
if (activeWrite) {
|
||||||
|
await activeWrite;
|
||||||
|
}
|
||||||
|
|
||||||
await db.write();
|
await db.write();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { isEmptyObject, makeString, removeUndefined } from '../parserUtils.js';
|
import { makeString } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { isEmptyObject, removeUndefined } from '../parserUtils.js';
|
||||||
|
|
||||||
describe('isEmptyObject()', () => {
|
describe('isEmptyObject()', () => {
|
||||||
test('finds an empty object', () => {
|
test('finds an empty object', () => {
|
||||||
|
|||||||
@@ -1,17 +1,5 @@
|
|||||||
export type ErrorEmitter = (message: string) => void;
|
export type ErrorEmitter = (message: string) => void;
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Ensures variable is string, it skips object types
|
|
||||||
* @param val - variable to convert
|
|
||||||
* @param {string} [fallback=''] - fallback value
|
|
||||||
* @returns {string} - value as string or fallback if not possible
|
|
||||||
*/
|
|
||||||
export const makeString = (val: unknown, fallback = ''): string => {
|
|
||||||
if (typeof val === 'string') return val.trim();
|
|
||||||
else if (val == null || val.constructor === Object) return fallback;
|
|
||||||
return val.toString().trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Verifies if object is empty
|
* @description Verifies if object is empty
|
||||||
* @param {object} obj
|
* @param {object} obj
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ test('CRUD operations on the rundown', async ({ page }) => {
|
|||||||
// test quick add options - star2+5-t is last end
|
// test quick add options - star2+5-t is last end
|
||||||
await page.getByTestId('entry-2').getByTestId('time-input-duration').fill('20m');
|
await page.getByTestId('entry-2').getByTestId('time-input-duration').fill('20m');
|
||||||
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||||
expect(await page.getByTestId('entry-3').getByTestId('time-input-timeStart').inputValue()).toContain('00:30:00');
|
await expect(page.getByTestId('entry-3').getByTestId('time-input-timeStart')).toHaveValue('00:30:00');
|
||||||
await expect(page.getByTestId('rundown-event')).toHaveCount(3);
|
await expect(page.getByTestId('rundown-event')).toHaveCount(3);
|
||||||
await expect(page.getByTestId('rundown-delay')).toHaveCount(1);
|
await expect(page.getByTestId('rundown-delay')).toHaveCount(1);
|
||||||
await expect(page.getByTestId('rundown-group')).toHaveCount(1);
|
await expect(page.getByTestId('rundown-group')).toHaveCount(1);
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "4.4.0",
|
"version": "4.4.2",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ontime",
|
"ontime",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "4.4.0",
|
"version": "4.4.2",
|
||||||
"name": "ontime-types",
|
"name": "ontime-types",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
|
|||||||
@@ -8,29 +8,34 @@ export { sanitiseCue } from './src/cue-utils/cueUtils.js';
|
|||||||
export { getCueCandidate } from './src/cue-utils/cueUtils.js';
|
export { getCueCandidate } from './src/cue-utils/cueUtils.js';
|
||||||
export { generateId } from './src/generate-id/generateId.js';
|
export { generateId } from './src/generate-id/generateId.js';
|
||||||
export {
|
export {
|
||||||
|
addToRundown,
|
||||||
getEventWithId,
|
getEventWithId,
|
||||||
getFirstEvent,
|
getFirstEvent,
|
||||||
getFirstEventNormal,
|
getFirstEventNormal,
|
||||||
getFirstNormal,
|
getFirstNormal,
|
||||||
getFirstGroupNormal,
|
getFirstGroupNormal,
|
||||||
|
getInsertAfterId,
|
||||||
getLastEvent,
|
getLastEvent,
|
||||||
getLastEventNormal,
|
getLastEventNormal,
|
||||||
getLastNormal,
|
getLastNormal,
|
||||||
getLastGroupNormal,
|
getLastGroupNormal,
|
||||||
getNext,
|
|
||||||
getNextGroupNormal,
|
getNextGroupNormal,
|
||||||
getNextEvent,
|
getNextEvent,
|
||||||
getNextEventNormal,
|
getNextEventNormal,
|
||||||
getNextNormal,
|
getNextNormal,
|
||||||
getPrevious,
|
|
||||||
getPreviousEvent,
|
|
||||||
getPreviousEventNormal,
|
getPreviousEventNormal,
|
||||||
getPreviousNormal,
|
getPreviousNormal,
|
||||||
getPreviousGroup,
|
|
||||||
getPreviousGroupNormal,
|
getPreviousGroupNormal,
|
||||||
|
resolveInsertParent,
|
||||||
swapEventData,
|
swapEventData,
|
||||||
} from './src/rundown-utils/rundownUtils.js';
|
} from './src/rundown-utils/rundownUtils.js';
|
||||||
export { getFirstRundown } from './src/rundown/rundown.utils.js';
|
export { getFirstRundown } from './src/rundown/rundown.utils.js';
|
||||||
|
export {
|
||||||
|
event as eventDef,
|
||||||
|
group as groupDef,
|
||||||
|
milestone as milestoneDef,
|
||||||
|
} from './src/rundown-utils/entryDefinitions.js';
|
||||||
|
export { createDelay, createEvent, createGroup, createMilestone, makeString } from './src/rundown-utils/entryUtils.js';
|
||||||
|
|
||||||
// time format utils
|
// time format utils
|
||||||
export {
|
export {
|
||||||
|
|||||||
+2
-10
@@ -1,13 +1,5 @@
|
|||||||
import {
|
import type { OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
|
||||||
EndAction,
|
import { EndAction, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
OntimeGroup,
|
|
||||||
OntimeDelay,
|
|
||||||
OntimeEvent,
|
|
||||||
OntimeMilestone,
|
|
||||||
SupportedEntry,
|
|
||||||
TimeStrategy,
|
|
||||||
TimerType,
|
|
||||||
} from 'ontime-types';
|
|
||||||
|
|
||||||
export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
|
export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
|
||||||
type: SupportedEntry.Event,
|
type: SupportedEntry.Event,
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import type { OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
|
||||||
|
import { SupportedEntry, TimeStrategy } from 'ontime-types';
|
||||||
|
|
||||||
|
import { generateId } from '../generate-id/generateId.js';
|
||||||
|
import { validateEndAction, validateTimerType } from '../validate-events/validateEvent.js';
|
||||||
|
import { validateTimes } from '../validate-times/validateTimes.js';
|
||||||
|
import { event as eventDef, group as groupDef, milestone as milestoneDef } from './entryDefinitions.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Ensures variable is string, it skips object types
|
||||||
|
* @param val - variable to convert
|
||||||
|
* @param {string} [fallback=''] - fallback value
|
||||||
|
* @returns {string} - value as string or fallback if not possible
|
||||||
|
*/
|
||||||
|
export const makeString = (val: unknown, fallback = ''): string => {
|
||||||
|
if (typeof val === 'string') return val.trim();
|
||||||
|
else if (val == null || val.constructor === Object) return fallback;
|
||||||
|
return val.toString().trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new delay from an optional patch
|
||||||
|
*/
|
||||||
|
export function createDelay(patch?: Partial<OntimeDelay>): OntimeDelay {
|
||||||
|
return {
|
||||||
|
type: SupportedEntry.Delay,
|
||||||
|
id: patch?.id ?? generateId(),
|
||||||
|
duration: patch?.duration ?? 0,
|
||||||
|
parent: patch?.parent ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Enforces formatting for events
|
||||||
|
* @param {object} eventArgs - attributes of event
|
||||||
|
* @param {number} eventIndex - can be a string when we pass the a suggested cue name
|
||||||
|
* @returns {object|null} - formatted object or null in case is invalid
|
||||||
|
*/
|
||||||
|
export const createEvent = (eventArgs: Partial<OntimeEvent>, eventIndex: number | string = ''): OntimeEvent | null => {
|
||||||
|
if (Object.keys(eventArgs).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cue = typeof eventIndex === 'number' ? String(eventIndex + 1) : eventIndex;
|
||||||
|
|
||||||
|
const baseEvent = {
|
||||||
|
id: eventArgs?.id ?? generateId(),
|
||||||
|
cue,
|
||||||
|
...eventDef,
|
||||||
|
};
|
||||||
|
const event = createEventPatch(baseEvent, eventArgs);
|
||||||
|
return event;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new group from an optional patch
|
||||||
|
*/
|
||||||
|
export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
|
||||||
|
if (!patch) {
|
||||||
|
return { ...groupDef, id: generateId() };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: patch.id ?? generateId(),
|
||||||
|
type: SupportedEntry.Group,
|
||||||
|
title: patch.title ?? '',
|
||||||
|
note: patch.note ?? '',
|
||||||
|
entries: patch.entries ?? [],
|
||||||
|
targetDuration: patch.targetDuration ?? null,
|
||||||
|
colour: makeString(patch.colour, ''),
|
||||||
|
custom: patch.custom ?? {},
|
||||||
|
revision: 0,
|
||||||
|
timeStart: null,
|
||||||
|
timeEnd: null,
|
||||||
|
duration: 0,
|
||||||
|
isFirstLinked: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new milestone from an optional patch
|
||||||
|
*/
|
||||||
|
export function createMilestone(patch?: Partial<OntimeMilestone>): OntimeMilestone {
|
||||||
|
if (!patch) {
|
||||||
|
return { ...milestoneDef, id: generateId() };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: patch.id ?? generateId(),
|
||||||
|
type: SupportedEntry.Milestone,
|
||||||
|
cue: patch.cue ?? '',
|
||||||
|
title: patch.title ?? '',
|
||||||
|
note: patch.note ?? '',
|
||||||
|
colour: makeString(patch.colour, ''),
|
||||||
|
custom: patch.custom ?? {},
|
||||||
|
parent: patch.parent ?? null,
|
||||||
|
revision: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function infers strategy for a patch with only partial timer data
|
||||||
|
*/
|
||||||
|
function inferStrategy(end: unknown, duration: unknown, fallback: TimeStrategy): TimeStrategy {
|
||||||
|
if (end && !duration) {
|
||||||
|
return TimeStrategy.LockEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!end && duration) {
|
||||||
|
return TimeStrategy.LockDuration;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEventPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
|
||||||
|
if (Object.keys(patchEvent).length === 0) {
|
||||||
|
return originalEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { timeStart, timeEnd, duration, timeStrategy } = validateTimes(
|
||||||
|
patchEvent?.timeStart ?? originalEvent.timeStart,
|
||||||
|
patchEvent?.timeEnd ?? originalEvent.timeEnd,
|
||||||
|
patchEvent?.duration ?? originalEvent.duration,
|
||||||
|
patchEvent?.timeStrategy ?? inferStrategy(patchEvent?.timeEnd, patchEvent?.duration, originalEvent.timeStrategy),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: originalEvent.id,
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
flag: typeof patchEvent.flag === 'boolean' ? patchEvent.flag : originalEvent.flag,
|
||||||
|
title: makeString(patchEvent.title, originalEvent.title),
|
||||||
|
timeStart,
|
||||||
|
timeEnd,
|
||||||
|
duration,
|
||||||
|
timeStrategy,
|
||||||
|
linkStart: typeof patchEvent.linkStart === 'boolean' ? patchEvent.linkStart : originalEvent.linkStart,
|
||||||
|
endAction: validateEndAction(patchEvent.endAction, originalEvent.endAction),
|
||||||
|
timerType: validateTimerType(patchEvent.timerType, originalEvent.timerType),
|
||||||
|
countToEnd: typeof patchEvent.countToEnd === 'boolean' ? patchEvent.countToEnd : originalEvent.countToEnd,
|
||||||
|
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
|
||||||
|
note: makeString(patchEvent.note, originalEvent.note),
|
||||||
|
colour: makeString(patchEvent.colour, originalEvent.colour),
|
||||||
|
delay: originalEvent.delay, // is regenerated if timer related data is changed
|
||||||
|
dayOffset: originalEvent.dayOffset, // is regenerated if timer related data is changed
|
||||||
|
gap: originalEvent.gap, // is regenerated if timer related data is changed
|
||||||
|
// short circuit empty string
|
||||||
|
cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
|
||||||
|
parent: originalEvent.parent,
|
||||||
|
revision: originalEvent.revision,
|
||||||
|
timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning,
|
||||||
|
timeDanger: patchEvent.timeDanger ?? originalEvent.timeDanger,
|
||||||
|
custom: { ...originalEvent.custom, ...patchEvent.custom },
|
||||||
|
triggers: patchEvent.triggers ?? originalEvent.triggers,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,71 +1,23 @@
|
|||||||
import type { OntimeDelay, OntimeEntry, OntimeEvent, OntimeGroup } from 'ontime-types';
|
import type { OntimeDelay, OntimeEntry, OntimeEvent, OntimeGroup, Rundown } from 'ontime-types';
|
||||||
import { SupportedEntry } from 'ontime-types';
|
import { SupportedEntry } from 'ontime-types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
addToRundown,
|
||||||
getFirstGroupNormal,
|
getFirstGroupNormal,
|
||||||
|
getInsertAfterId,
|
||||||
getLastEvent,
|
getLastEvent,
|
||||||
getLastGroupNormal,
|
getLastGroupNormal,
|
||||||
getLastNormal,
|
getLastNormal,
|
||||||
getNext,
|
|
||||||
getNextEvent,
|
getNextEvent,
|
||||||
getNextGroupNormal,
|
getNextGroupNormal,
|
||||||
getNextNormal,
|
getNextNormal,
|
||||||
getPrevious,
|
|
||||||
getPreviousEvent,
|
|
||||||
getPreviousGroup,
|
|
||||||
getPreviousGroupNormal,
|
getPreviousGroupNormal,
|
||||||
getPreviousNormal,
|
getPreviousNormal,
|
||||||
|
resolveInsertParent,
|
||||||
swapEventData,
|
swapEventData,
|
||||||
} from './rundownUtils';
|
} from './rundownUtils';
|
||||||
import { demoDb } from './rundownUtils.mock';
|
import { demoDb } from './rundownUtils.mock';
|
||||||
|
|
||||||
describe('getNext()', () => {
|
|
||||||
it('returns the next event of type event', () => {
|
|
||||||
const testRundown = {
|
|
||||||
entries: {
|
|
||||||
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
'3': { id: '3', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
},
|
|
||||||
order: ['1', '2', '3'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const { nextEvent, nextIndex } = getNext(testRundown, '1');
|
|
||||||
expect(nextEvent?.id).toBe('2');
|
|
||||||
expect(nextIndex).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns any type of OntimeEntry ', () => {
|
|
||||||
const testRundown = {
|
|
||||||
entries: {
|
|
||||||
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
'2': { id: '2', type: SupportedEntry.Delay } as OntimeDelay,
|
|
||||||
'3': { id: '3', type: SupportedEntry.Group } as OntimeGroup,
|
|
||||||
'4': { id: '4', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
},
|
|
||||||
order: ['1', '2', '3', '4'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const { nextEvent, nextIndex } = getNext(testRundown, '1');
|
|
||||||
expect(nextEvent?.id).toBe('2');
|
|
||||||
expect(nextIndex).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null if none found', () => {
|
|
||||||
const testRundown = {
|
|
||||||
entries: {
|
|
||||||
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
'3': { id: '3', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
},
|
|
||||||
order: ['1', '2', '3'],
|
|
||||||
};
|
|
||||||
const { nextEvent, nextIndex } = getNext(testRundown, '3');
|
|
||||||
expect(nextEvent).toBe(null);
|
|
||||||
expect(nextIndex).toBe(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getNextEvent()', () => {
|
describe('getNextEvent()', () => {
|
||||||
it('returns the next event of type event', () => {
|
it('returns the next event of type event', () => {
|
||||||
const testRundown = [
|
const testRundown = [
|
||||||
@@ -105,101 +57,6 @@ describe('getNextEvent()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getPrevious()', () => {
|
|
||||||
it('returns the previous event of type event', () => {
|
|
||||||
const testRundown = {
|
|
||||||
entries: {
|
|
||||||
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
'3': { id: '3', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
},
|
|
||||||
order: ['1', '2', '3'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const { entry, index } = getPrevious(testRundown, '3');
|
|
||||||
expect(entry?.id).toBe('2');
|
|
||||||
expect(index).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allow other event types', () => {
|
|
||||||
const testRundown = {
|
|
||||||
entries: {
|
|
||||||
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
'2': { id: '2', type: SupportedEntry.Delay } as OntimeDelay,
|
|
||||||
'3': { id: '3', type: SupportedEntry.Group } as OntimeGroup,
|
|
||||||
'4': { id: '4', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
},
|
|
||||||
order: ['1', '2', '3', '4'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const { entry, index } = getPrevious(testRundown, '3');
|
|
||||||
expect(entry?.id).toBe('2');
|
|
||||||
expect(index).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null if none found', () => {
|
|
||||||
const testRundown = {
|
|
||||||
entries: {
|
|
||||||
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
'3': { id: '3', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
},
|
|
||||||
order: ['1', '2', '3'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const { entry, index } = getPrevious(testRundown, '2');
|
|
||||||
expect(entry).toBe(null);
|
|
||||||
expect(index).toBe(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getPreviousEvent()', () => {
|
|
||||||
it('returns the previous event of type event', () => {
|
|
||||||
const testRundown = {
|
|
||||||
entries: {
|
|
||||||
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
'2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
'3': { id: '3', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
},
|
|
||||||
order: ['1', '2', '3'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const { previousEvent, previousIndex } = getPreviousEvent(testRundown, '3');
|
|
||||||
expect(previousEvent?.id).toBe('2');
|
|
||||||
expect(previousIndex).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores other event types', () => {
|
|
||||||
const testRundown = {
|
|
||||||
entries: {
|
|
||||||
'1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
'2': { id: '2', type: SupportedEntry.Delay } as OntimeDelay,
|
|
||||||
'3': { id: '3', type: SupportedEntry.Group } as OntimeGroup,
|
|
||||||
'4': { id: '4', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
},
|
|
||||||
order: ['1', '2', '3', '4'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const { previousEvent, previousIndex } = getPreviousEvent(testRundown, '4');
|
|
||||||
expect(previousEvent?.id).toBe('1');
|
|
||||||
expect(previousIndex).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null if none found', () => {
|
|
||||||
const testRundown = {
|
|
||||||
entries: {
|
|
||||||
'2': { id: '2', type: SupportedEntry.Delay } as OntimeDelay,
|
|
||||||
'3': { id: '3', type: SupportedEntry.Group } as OntimeGroup,
|
|
||||||
'4': { id: '4', type: SupportedEntry.Event } as OntimeEvent,
|
|
||||||
},
|
|
||||||
order: ['2', '3', '4'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const { previousEvent, previousIndex } = getPreviousEvent(testRundown, '2');
|
|
||||||
expect(previousEvent).toBe(null);
|
|
||||||
expect(previousIndex).toBe(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('swapEventData', () => {
|
describe('swapEventData', () => {
|
||||||
it('swaps some data between two events', () => {
|
it('swaps some data between two events', () => {
|
||||||
const eventA = {
|
const eventA = {
|
||||||
@@ -372,56 +229,163 @@ describe('getLastEvent', () => {
|
|||||||
expect(lastEntry).toBe(null);
|
expect(lastEntry).toBe(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getPreviousGroup()', () => {
|
describe('getInsertAfterId()', () => {
|
||||||
const testRundown = {
|
const rundown = {
|
||||||
entries: {
|
id: 'test',
|
||||||
a: { id: 'a', type: SupportedEntry.Event } as OntimeEvent,
|
title: 'test',
|
||||||
b: { id: 'b', type: SupportedEntry.Event } as OntimeEvent,
|
entries: {
|
||||||
c: { id: 'c', type: SupportedEntry.Event } as OntimeEvent,
|
'1': { id: '1', type: SupportedEntry.Event, parent: null } as OntimeEvent,
|
||||||
d: { id: 'd', type: SupportedEntry.Delay } as OntimeDelay,
|
'2': { id: '2', type: SupportedEntry.Event, parent: null } as OntimeEvent,
|
||||||
e: { id: 'e', type: SupportedEntry.Group } as OntimeGroup,
|
group: { id: 'group', type: SupportedEntry.Group, entries: ['31', '32'] } as unknown as OntimeGroup,
|
||||||
f: { id: 'f', type: SupportedEntry.Event } as OntimeEvent,
|
'31': { id: '31', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent,
|
||||||
g: { id: 'g', type: SupportedEntry.Group } as OntimeGroup,
|
'32': { id: '32', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent,
|
||||||
h: { id: 'h', type: SupportedEntry.Event } as OntimeEvent,
|
'4': { id: '4', type: SupportedEntry.Event, parent: null } as OntimeEvent,
|
||||||
},
|
},
|
||||||
order: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'],
|
order: ['1', '2', 'group', '4'],
|
||||||
};
|
flatOrder: ['1', '2', 'group', '31', '32', '4'],
|
||||||
|
revision: 1,
|
||||||
|
} as Rundown;
|
||||||
|
|
||||||
test.each([
|
it('returns afterId if provided', () => {
|
||||||
['h', 'g'],
|
expect(getInsertAfterId(rundown, null, 'b')).toBe('b');
|
||||||
['f', 'e'],
|
});
|
||||||
])('returns the relevant group', (id, expected) => {
|
|
||||||
const group = getPreviousGroup(testRundown, id);
|
|
||||||
expect(group?.id).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null if there is no parent group relevant group', () => {
|
it('returns null if neither afterId nor beforeId is provided', () => {
|
||||||
const group = getPreviousGroup(testRundown, 'a');
|
expect(getInsertAfterId(rundown, null)).toBeNull();
|
||||||
expect(group).toBe(null);
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it('also works on index 0', () => {
|
it('returns null if beforeId is not found', () => {
|
||||||
testRundown.order.unshift('0');
|
expect(getInsertAfterId(rundown, null, undefined, 'z')).toBeNull();
|
||||||
// @ts-expect-error -- we are adding an event to the rundown
|
expect(getInsertAfterId(rundown, null, undefined, '1')).toBeNull();
|
||||||
testRundown.entries['0'] = { id: '0', type: SupportedEntry.Group } as OntimeGroup;
|
});
|
||||||
const group = getPreviousGroup(testRundown, 'a');
|
|
||||||
expect(group?.id).toBe('0');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the parent group if nested event', () => {
|
it('returns the previous id of an entry in the rundown', () => {
|
||||||
const testRundown = {
|
expect(getInsertAfterId(rundown, null, undefined, '2')).toBe('1');
|
||||||
entries: {
|
expect(getInsertAfterId(rundown, null, undefined, '4')).toBe('group');
|
||||||
1: { id: '1', type: SupportedEntry.Event } as OntimeEvent,
|
expect(getInsertAfterId(rundown, null, undefined, 'group')).toBe('2');
|
||||||
group: { id: 'group', type: SupportedEntry.Group, entries: ['21', '22', '23'] } as OntimeGroup,
|
});
|
||||||
21: { id: '21', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent,
|
|
||||||
22: { id: '22', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent,
|
it('returns the previous id of an event in a group', () => {
|
||||||
23: { id: '23', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent,
|
expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '31')).toBeNull();
|
||||||
},
|
expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '32')).toBe('31');
|
||||||
order: ['1', 'group'],
|
});
|
||||||
};
|
});
|
||||||
const group = getPreviousGroup(testRundown, '21');
|
|
||||||
expect(group?.id).toBe('group');
|
describe('resolveInsertParent()', () => {
|
||||||
});
|
const rundown = {
|
||||||
|
id: 'test',
|
||||||
|
title: 'test',
|
||||||
|
entries: {
|
||||||
|
top: { id: 'top', type: SupportedEntry.Event, parent: null } as OntimeEvent,
|
||||||
|
group: { id: 'group', type: SupportedEntry.Group, entries: ['31', '32'] } as unknown as OntimeGroup,
|
||||||
|
'31': { id: '31', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent,
|
||||||
|
'32': { id: '32', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent,
|
||||||
|
},
|
||||||
|
order: ['top', 'group'],
|
||||||
|
flatOrder: ['top', 'group', '31', '32'],
|
||||||
|
revision: 1,
|
||||||
|
} as Rundown;
|
||||||
|
|
||||||
|
it('returns explicit parent id', () => {
|
||||||
|
expect(resolveInsertParent(rundown, { parent: 'group' })).toBe('group');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns non-existent explicit parent id as-is', () => {
|
||||||
|
expect(resolveInsertParent(rundown, { parent: 'missing' })).toBe('missing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('infers parent id from sibling when parent is omitted', () => {
|
||||||
|
expect(resolveInsertParent(rundown, { after: '31' })).toBe('group');
|
||||||
|
expect(resolveInsertParent(rundown, { before: '32' })).toBe('group');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no grouped sibling reference exists', () => {
|
||||||
|
expect(resolveInsertParent(rundown, { after: 'top' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no references are provided', () => {
|
||||||
|
expect(resolveInsertParent(rundown, {})).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addToRundown()', () => {
|
||||||
|
const makeTestRundown = (): Rundown =>
|
||||||
|
({
|
||||||
|
id: 'test',
|
||||||
|
title: 'test',
|
||||||
|
entries: {
|
||||||
|
'1': { id: '1', type: SupportedEntry.Event, parent: null } as OntimeEvent,
|
||||||
|
'2': { id: '2', type: SupportedEntry.Event, parent: null } as OntimeEvent,
|
||||||
|
group: { id: 'group', type: SupportedEntry.Group, entries: ['31', '32'] } as unknown as OntimeGroup,
|
||||||
|
'31': { id: '31', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent,
|
||||||
|
'32': { id: '32', type: SupportedEntry.Event, parent: 'group' } as OntimeEvent,
|
||||||
|
},
|
||||||
|
order: ['1', '2', 'group'],
|
||||||
|
flatOrder: ['1', '2', 'group', '31', '32'],
|
||||||
|
revision: 1,
|
||||||
|
}) as Rundown;
|
||||||
|
|
||||||
|
it('adds an entry to an empty rundown', () => {
|
||||||
|
const rundown = { id: 'test', title: '', entries: {}, order: [], flatOrder: [], revision: 0 } as Rundown;
|
||||||
|
const newEntry = { id: 'new', type: SupportedEntry.Event } as OntimeEvent;
|
||||||
|
|
||||||
|
addToRundown(rundown, newEntry, null, null);
|
||||||
|
|
||||||
|
expect(rundown.order).toEqual(['new']);
|
||||||
|
expect(rundown.flatOrder).toEqual(['new']);
|
||||||
|
expect(rundown.entries['new']).toBe(newEntry);
|
||||||
|
});
|
||||||
|
|
||||||
|
// case 2b: insert at the beginning of the rundown
|
||||||
|
it('adds at the beginning of order and flatOrder when afterId is null', () => {
|
||||||
|
const rundown = makeTestRundown();
|
||||||
|
const newEntry = { id: 'new', type: SupportedEntry.Event } as OntimeEvent;
|
||||||
|
|
||||||
|
addToRundown(rundown, newEntry, null, null);
|
||||||
|
|
||||||
|
expect(rundown.order).toEqual(['new', '1', '2', 'group']);
|
||||||
|
expect(rundown.flatOrder).toEqual(['new', '1', '2', 'group', '31', '32']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// case 2a: insert after a given entry at top level
|
||||||
|
it('inserts after the referenced entry in both order and flatOrder', () => {
|
||||||
|
const rundown = makeTestRundown();
|
||||||
|
const newEntry = { id: 'new', type: SupportedEntry.Event } as OntimeEvent;
|
||||||
|
|
||||||
|
addToRundown(rundown, newEntry, '1', null);
|
||||||
|
|
||||||
|
expect(rundown.order).toEqual(['1', 'new', '2', 'group']);
|
||||||
|
expect(rundown.flatOrder).toEqual(['1', 'new', '2', 'group', '31', '32']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// case 1b: insert at the beginning of a group
|
||||||
|
it('inserts right after the group header in flatOrder and sets parent', () => {
|
||||||
|
const rundown = makeTestRundown();
|
||||||
|
const parent = rundown.entries['group'] as OntimeGroup;
|
||||||
|
const newEntry = { id: 'new', type: SupportedEntry.Event, parent: null } as OntimeEvent;
|
||||||
|
|
||||||
|
addToRundown(rundown, newEntry, null, parent);
|
||||||
|
|
||||||
|
expect(parent.entries).toEqual(['new', '31', '32']);
|
||||||
|
expect(newEntry.parent).toBe('group');
|
||||||
|
expect(rundown.flatOrder).toEqual(['1', '2', 'group', 'new', '31', '32']);
|
||||||
|
// top-level order must not change when inserting into a group
|
||||||
|
expect(rundown.order).toEqual(['1', '2', 'group']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// case 1a: insert after a given entry within a group
|
||||||
|
it('inserts after the referenced entry within the group and in flatOrder', () => {
|
||||||
|
const rundown = makeTestRundown();
|
||||||
|
const parent = rundown.entries['group'] as OntimeGroup;
|
||||||
|
const newEntry = { id: 'new', type: SupportedEntry.Event, parent: null } as OntimeEvent;
|
||||||
|
|
||||||
|
addToRundown(rundown, newEntry, '31', parent);
|
||||||
|
|
||||||
|
expect(parent.entries).toEqual(['31', 'new', '32']);
|
||||||
|
expect(newEntry.parent).toBe('group');
|
||||||
|
expect(rundown.flatOrder).toEqual(['1', '2', 'group', '31', 'new', '32']);
|
||||||
|
expect(rundown.order).toEqual(['1', '2', 'group']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { isOntimeEvent, isOntimeGroup, isPlayableEvent } from 'ontime-types';
|
import { isOntimeEvent, isOntimeGroup, isPlayableEvent } from 'ontime-types';
|
||||||
|
|
||||||
|
import { insertAtIndex } from '../common/arrayUtils.js';
|
||||||
|
|
||||||
type IndexAndEntry = { entry: OntimeEntry | null; index: number | null };
|
type IndexAndEntry = { entry: OntimeEntry | null; index: number | null };
|
||||||
type GroupIndexAndEntry = { entry: OntimeGroup | null; index: number | null };
|
type GroupIndexAndEntry = { entry: OntimeGroup | null; index: number | null };
|
||||||
|
|
||||||
@@ -114,24 +116,6 @@ export function getLastEventNormal(
|
|||||||
return { lastEvent: null, lastIndex: null };
|
return { lastEvent: null, lastIndex: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets next entry in rundown, if it exists
|
|
||||||
*/
|
|
||||||
export function getNext(
|
|
||||||
rundown: Pick<Rundown, 'entries' | 'order'>,
|
|
||||||
currentId: EntryId,
|
|
||||||
): { nextEvent: OntimeEntry | null; nextIndex: number | null } {
|
|
||||||
const index = rundown.order.findIndex((entryId) => entryId === currentId);
|
|
||||||
if (index !== -1 && index + 1 < rundown.order.length) {
|
|
||||||
const nextIndex = index + 1;
|
|
||||||
const nextId = rundown.order[nextIndex];
|
|
||||||
const nextEvent = rundown.entries[nextId];
|
|
||||||
return { nextEvent, nextIndex };
|
|
||||||
} else {
|
|
||||||
return { nextEvent: null, nextIndex: null };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets next entry in rundown, if it exists
|
* Gets next entry in rundown, if it exists
|
||||||
*/
|
*/
|
||||||
@@ -195,22 +179,6 @@ export function getNextEventNormal(
|
|||||||
return { nextEvent: null, nextIndex: null };
|
return { nextEvent: null, nextIndex: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets previous entry in rundown, if it exists
|
|
||||||
*/
|
|
||||||
export function getPrevious(rundown: Pick<Rundown, 'entries' | 'order'>, currentId: EntryId): IndexAndEntry {
|
|
||||||
const currentIndex = rundown.order.findIndex((entryId) => entryId === currentId);
|
|
||||||
|
|
||||||
if (currentIndex > 1) {
|
|
||||||
const index = currentIndex - 1;
|
|
||||||
const previousId = rundown.order[index];
|
|
||||||
const entry = rundown.entries[previousId];
|
|
||||||
return { entry, index };
|
|
||||||
} else {
|
|
||||||
return { entry: null, index: null };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets previous entry in a normalised rundown, if it exists
|
* Gets previous entry in a normalised rundown, if it exists
|
||||||
*/
|
*/
|
||||||
@@ -262,27 +230,6 @@ export function getLastGroupNormal(entries: RundownEntries, flatOrder: EntryId[]
|
|||||||
return { entry: null, index: null };
|
return { entry: null, index: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets previous scheduled event in rundown, if it exists
|
|
||||||
*/
|
|
||||||
export function getPreviousEvent(
|
|
||||||
rundown: Pick<Rundown, 'entries' | 'order'>,
|
|
||||||
currentId: EntryId,
|
|
||||||
): { previousEvent: OntimeEvent | null; previousIndex: number | null } {
|
|
||||||
const index = rundown.order.findIndex((entryId) => entryId === currentId);
|
|
||||||
if (index < 0) {
|
|
||||||
return { previousEvent: null, previousIndex: null };
|
|
||||||
}
|
|
||||||
for (let i = index - 1; i >= 0; i--) {
|
|
||||||
const previousId = rundown.order[i];
|
|
||||||
const previousEvent = rundown.entries[previousId];
|
|
||||||
if (isOntimeEvent(previousEvent)) {
|
|
||||||
return { previousEvent, previousIndex: i };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { previousEvent: null, previousIndex: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets previous scheduled event in a normalised rundown, if it exists
|
* Gets previous scheduled event in a normalised rundown, if it exists
|
||||||
*/
|
*/
|
||||||
@@ -415,31 +362,102 @@ export function getNextGroupNormal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets relevant group element for a given ID
|
* Receives an insertion order and returns the reference to an entry ID
|
||||||
|
* after which we will insert the new entry
|
||||||
*/
|
*/
|
||||||
export function getPreviousGroup(rundown: Pick<Rundown, 'entries' | 'order'>, currentId: EntryId): OntimeGroup | null {
|
export function getInsertAfterId(
|
||||||
const currentEvent = rundown.entries[currentId];
|
rundown: Rundown,
|
||||||
|
parent: OntimeGroup | null,
|
||||||
|
afterId?: EntryId,
|
||||||
|
beforeId?: EntryId,
|
||||||
|
): EntryId | null {
|
||||||
|
if (afterId) return afterId;
|
||||||
|
if (!beforeId) return null;
|
||||||
|
|
||||||
// check if entry is inside a group
|
const insertionList = parent ? parent.entries : rundown.order;
|
||||||
if ('parent' in currentEvent && currentEvent.parent) {
|
if (!insertionList || insertionList.length === 0) return null;
|
||||||
return rundown.entries[currentEvent.parent] as OntimeGroup;
|
|
||||||
|
const atIndex = insertionList.findIndex((id) => id === beforeId);
|
||||||
|
if (atIndex < 1) return null;
|
||||||
|
return insertionList[atIndex - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolveInsertParentOptions = {
|
||||||
|
parent?: EntryId | null;
|
||||||
|
after?: EntryId;
|
||||||
|
before?: EntryId;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the parent ID for an insertion.
|
||||||
|
* Uses explicit parent first, then infers from sibling references.
|
||||||
|
*/
|
||||||
|
export function resolveInsertParent(rundown: Rundown, options: ResolveInsertParentOptions): EntryId | null {
|
||||||
|
if (options.parent) {
|
||||||
|
return options.parent;
|
||||||
}
|
}
|
||||||
|
|
||||||
let foundCurrentEvent = false;
|
const referenceId = options.after ?? options.before;
|
||||||
// Iterate backwards through the rundown to find the current event
|
if (!referenceId) return null;
|
||||||
for (let i = rundown.order.length - 1; i >= 0; i--) {
|
|
||||||
const entryId = rundown.order[i];
|
const maybeSibling = rundown.entries[referenceId];
|
||||||
const entry = rundown.entries[entryId];
|
if (maybeSibling && 'parent' in maybeSibling && maybeSibling.parent) {
|
||||||
if (!foundCurrentEvent && entry.id === currentId) {
|
return maybeSibling.parent;
|
||||||
// set the flag when the current event is found
|
|
||||||
foundCurrentEvent = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// the first group before the current event is the relevant one
|
|
||||||
if (foundCurrentEvent && isOntimeGroup(entry)) {
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// no groups exist before null event
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add entry to rundown, mutates the rundown in place.
|
||||||
|
* Handles the following cases:
|
||||||
|
* - 1a. add entry in group, after a given entry
|
||||||
|
* - 1b. add entry in group, at the beginning (right after the group header)
|
||||||
|
* - 2a. add entry to the rundown, after a given entry
|
||||||
|
* - 2b. add entry to the rundown, at the beginning
|
||||||
|
*/
|
||||||
|
export function addToRundown(
|
||||||
|
rundown: Rundown,
|
||||||
|
entry: OntimeEntry,
|
||||||
|
afterId: EntryId | null,
|
||||||
|
parent: OntimeGroup | null,
|
||||||
|
): OntimeEntry {
|
||||||
|
if (parent) {
|
||||||
|
// 1. inserting an entry inside a group
|
||||||
|
|
||||||
|
// assign the parent reference on the entry
|
||||||
|
if ('parent' in entry) {
|
||||||
|
entry.parent = parent.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (afterId) {
|
||||||
|
// 1a. insert after a given entry within the group
|
||||||
|
const atEventsIndex = parent.entries.indexOf(afterId) + 1;
|
||||||
|
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
|
||||||
|
parent.entries = insertAtIndex(atEventsIndex, entry.id, parent.entries);
|
||||||
|
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
||||||
|
} else {
|
||||||
|
// 1b. insert at the beginning of the group (right after the group header in flatOrder)
|
||||||
|
parent.entries = insertAtIndex(0, entry.id, parent.entries);
|
||||||
|
const atFlatIndex = rundown.flatOrder.indexOf(parent.id) + 1;
|
||||||
|
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 2. inserting an entry at top level
|
||||||
|
if (afterId) {
|
||||||
|
// 2a. insert after a given entry
|
||||||
|
const atOrderIndex = rundown.order.indexOf(afterId) + 1;
|
||||||
|
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
|
||||||
|
rundown.order = insertAtIndex(atOrderIndex, entry.id, rundown.order);
|
||||||
|
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
||||||
|
} else {
|
||||||
|
// 2b. insert at the beginning
|
||||||
|
rundown.order = insertAtIndex(0, entry.id, rundown.order);
|
||||||
|
rundown.flatOrder = insertAtIndex(0, entry.id, rundown.flatOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// either way, we register the entry in the entries map
|
||||||
|
rundown.entries[entry.id] = entry;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user