mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-02 22:18:08 +00:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ef0b81743 | |||
| 5a5450d7ef | |||
| 6f1f10ccff | |||
| 5ff7861968 | |||
| f1a1c358bc | |||
| efd19353e9 | |||
| a29a493678 | |||
| 27b179276d | |||
| 155cf48a17 | |||
| 358ad79ae4 | |||
| c1fcdf7065 | |||
| 1fe58e21be | |||
| 3c0e7ba4d5 | |||
| e8f159d894 | |||
| 168d7103b1 | |||
| 63d7250a70 | |||
| 88f21e2186 | |||
| d9a18b0c42 | |||
| df588b8845 | |||
| 5be504ce8f | |||
| 6268e327b9 | |||
| 1c81b3a23c | |||
| d28895ce9c | |||
| 1a23287863 | |||
| 0b3ba65df3 | |||
| c3a7cd6a39 | |||
| 39e6c15adf | |||
| aec6673b07 | |||
| 9e8cb1c755 | |||
| cdf02f7c3f | |||
| 77779a4648 | |||
| e16ec3f388 | |||
| 63cfc9ec19 | |||
| 4827117163 | |||
| 5777ef3532 | |||
| 80a3b04493 | |||
| 7c5fdd3b19 | |||
| 31fd8e5200 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.4.0",
|
||||
"version": "4.5.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.4.0",
|
||||
"version": "4.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -3,6 +3,9 @@ import { Tooltip } from '@base-ui/react/tooltip';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
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 IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay';
|
||||
import { AppContextProvider } from './common/context/AppContext';
|
||||
|
||||
@@ -4,14 +4,15 @@ import { TranslationObject } from 'ontime-types';
|
||||
import { ontimeQueryClient } from '../../common/queryClient';
|
||||
|
||||
import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
const assetsPath = `${apiEntryUrl}/assets`;
|
||||
|
||||
/**
|
||||
* HTTP request to get css contents
|
||||
*/
|
||||
export async function getCSSContents(): Promise<string> {
|
||||
const res = await axios.get(`${assetsPath}/css`);
|
||||
export async function getCSSContents(options?: RequestOptions): Promise<string> {
|
||||
const res = await axios.get(`${assetsPath}/css`, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -35,8 +36,8 @@ export async function restoreCSSContents(): Promise<string> {
|
||||
/**
|
||||
* HTTP request to get user translation
|
||||
*/
|
||||
export async function getUserTranslation(): Promise<TranslationObject> {
|
||||
const res = await axios.get(customTranslationsURL);
|
||||
export async function getUserTranslation(options?: RequestOptions): Promise<TranslationObject> {
|
||||
const res = await axios.get(customTranslationsURL, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,14 +9,15 @@ import type {
|
||||
} from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
const automationsPath = `${apiEntryUrl}/automations`;
|
||||
|
||||
/**
|
||||
* HTTP request to get the automations settings
|
||||
*/
|
||||
export async function getAutomationSettings(): Promise<AutomationSettings> {
|
||||
const res = await axios.get(automationsPath);
|
||||
export async function getAutomationSettings(options?: RequestOptions): Promise<AutomationSettings> {
|
||||
const res = await axios.get(automationsPath, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import axios from 'axios';
|
||||
|
||||
import { axiosConfig } from './requestTimeouts';
|
||||
|
||||
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 { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
const customFieldsPath = `${apiEntryUrl}/custom-fields`;
|
||||
|
||||
/**
|
||||
* Requests list of known custom fields
|
||||
*/
|
||||
export async function getCustomFields(): Promise<CustomFields> {
|
||||
const res = await axios.get(customFieldsPath);
|
||||
export async function getCustomFields(options?: RequestOptions): Promise<CustomFields> {
|
||||
const res = await axios.get(customFieldsPath, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import axios, { AxiosResponse } from 'axios';
|
||||
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
import { axiosConfig } from './requestTimeouts';
|
||||
import { createBlob, downloadBlob } from './utils';
|
||||
|
||||
const dbPath = `${apiEntryUrl}/db`;
|
||||
@@ -10,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
|
||||
* HTTP request to the current DB
|
||||
*/
|
||||
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();
|
||||
formData.append('project', file);
|
||||
const response = await axios.post(`${dbPath}/upload`, formData, {
|
||||
timeout: axiosConfig.longTimeout,
|
||||
headers: {
|
||||
'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
|
||||
*/
|
||||
export async function getProjects(): Promise<ProjectFileListResponse> {
|
||||
const res = await axios.get(`${dbPath}/all`);
|
||||
export async function getProjects(options?: RequestOptions): Promise<ProjectFileListResponse> {
|
||||
const res = await axios.get(`${dbPath}/all`, {
|
||||
signal: options?.signal,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
import { axiosConfig } from './requestTimeouts';
|
||||
import { downloadBlob } from './utils';
|
||||
|
||||
const excelPath = `${apiEntryUrl}/excel`;
|
||||
@@ -11,10 +13,12 @@ const excelPath = `${apiEntryUrl}/excel`;
|
||||
* upload Excel file to server
|
||||
* @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();
|
||||
formData.append('excel', file);
|
||||
const response = await axios.post(`${excelPath}/upload`, formData, {
|
||||
signal: requestOptions?.signal,
|
||||
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
@@ -27,19 +31,31 @@ type PreviewSpreadsheetResponse = {
|
||||
customFields: CustomFields;
|
||||
summary: RundownSummary;
|
||||
};
|
||||
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
|
||||
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
|
||||
options,
|
||||
});
|
||||
export async function importRundownPreview(
|
||||
options: ImportMap,
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const response = await axios.get(`${excelPath}/${rundownId}/export`, {
|
||||
signal: requestOptions?.signal,
|
||||
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import axios from 'axios';
|
||||
|
||||
import { apiRepoLatest } from '../../externals';
|
||||
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
export type HasUpdate = {
|
||||
url: string;
|
||||
version: string;
|
||||
@@ -10,8 +12,8 @@ export type HasUpdate = {
|
||||
/**
|
||||
* HTTP request to get the latest version and url from github
|
||||
*/
|
||||
export async function getLatestVersion(): Promise<HasUpdate> {
|
||||
const res = await axios.get(apiRepoLatest);
|
||||
export async function getLatestVersion(options?: RequestOptions): Promise<HasUpdate> {
|
||||
const res = await axios.get(apiRepoLatest, { signal: options?.signal });
|
||||
return {
|
||||
url: res.data.html_url 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 { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
import { axiosConfig } from './requestTimeouts';
|
||||
|
||||
const projectPath = `${apiEntryUrl}/project`;
|
||||
|
||||
/**
|
||||
* HTTP request to fetch project data
|
||||
*/
|
||||
export async function getProjectData(): Promise<ProjectData> {
|
||||
const res = await axios.get(projectPath);
|
||||
export async function getProjectData(options?: RequestOptions): Promise<ProjectData> {
|
||||
const res = await axios.get(projectPath, {
|
||||
signal: options?.signal,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -27,6 +32,7 @@ export async function uploadProjectLogo(file: File): Promise<AxiosResponse<Proje
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
const response = await axios.post(`${projectPath}/upload`, formData, {
|
||||
timeout: axiosConfig.longTimeout,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
|
||||
@@ -4,14 +4,15 @@ import { OntimeReport } from 'ontime-types';
|
||||
import { ontimeQueryClient } from '../../common/queryClient';
|
||||
|
||||
import { apiEntryUrl, REPORT } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
export const reportUrl = `${apiEntryUrl}/report`;
|
||||
|
||||
/**
|
||||
* HTTP request to fetch all reports
|
||||
*/
|
||||
export async function fetchReport(): Promise<OntimeReport> {
|
||||
const res = await axios.get(reportUrl);
|
||||
export async function fetchReport(options?: RequestOptions): Promise<OntimeReport> {
|
||||
const res = await axios.get(reportUrl, { signal: options?.signal });
|
||||
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 { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
type RundownId = string;
|
||||
const rundownPath = `${apiEntryUrl}/rundowns`;
|
||||
@@ -11,16 +12,16 @@ const rundownPath = `${apiEntryUrl}/rundowns`;
|
||||
/**
|
||||
* HTTP request to fetch a list of existing rundowns
|
||||
*/
|
||||
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
|
||||
const res = await axios.get(rundownPath);
|
||||
export async function fetchProjectRundownList(options?: RequestOptions): Promise<ProjectRundownsList> {
|
||||
const res = await axios.get(rundownPath, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to fetch all entries in the currently loaded rundown
|
||||
*/
|
||||
export async function fetchCurrentRundown(): Promise<Rundown> {
|
||||
const res = await axios.get(`${rundownPath}/current`);
|
||||
export async function fetchCurrentRundown(options?: RequestOptions): Promise<Rundown> {
|
||||
const res = await axios.get(`${rundownPath}/current`, { signal: options?.signal });
|
||||
if (!isValidRundown(res.data)) {
|
||||
throw new Error('Invalid rundown payload');
|
||||
}
|
||||
|
||||
@@ -2,14 +2,15 @@ import axios from 'axios';
|
||||
import { GetInfo, LinkOptions } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
const sessionPath = `${apiEntryUrl}/session`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve application info
|
||||
*/
|
||||
export async function getInfo(): Promise<GetInfo> {
|
||||
const res = await axios.get(`${sessionPath}/info`);
|
||||
export async function getInfo(options?: RequestOptions): Promise<GetInfo> {
|
||||
const res = await axios.get(`${sessionPath}/info`, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { Settings } from 'ontime-types';
|
||||
import { PortInfo, Settings } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
const settingsPath = `${apiEntryUrl}/settings`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve application settings
|
||||
*/
|
||||
export async function getSettings(): Promise<Settings> {
|
||||
const res = await axios.get(settingsPath);
|
||||
export async function getSettings(options?: RequestOptions): Promise<Settings> {
|
||||
const res = await axios.get(settingsPath, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -26,3 +27,18 @@ export async function postSettings(data: Settings): Promise<AxiosResponse<Settin
|
||||
export async function postShowWelcomeDialog(show: boolean) {
|
||||
axios.post(`${settingsPath}/welcomedialog`, { show });
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve server port
|
||||
*/
|
||||
export async function getServerPort(): Promise<PortInfo> {
|
||||
const res = await axios.get(`${settingsPath}/serverport`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to set server port
|
||||
*/
|
||||
export async function postServerPort(serverPort: number): Promise<AxiosResponse<PortInfo>> {
|
||||
return axios.post(`${settingsPath}/serverport`, { serverPort });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ont
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
import { axiosConfig } from './requestTimeouts';
|
||||
|
||||
const sheetsPath = `${apiEntryUrl}/sheets`;
|
||||
|
||||
@@ -23,6 +25,7 @@ export const verifyAuthenticationStatus = async (): Promise<{
|
||||
export const requestConnection = async (
|
||||
file: File,
|
||||
sheetId: string,
|
||||
requestOptions?: RequestOptions,
|
||||
): Promise<{
|
||||
verification_url: string;
|
||||
user_code: string;
|
||||
@@ -31,6 +34,8 @@ export const requestConnection = async (
|
||||
formData.append('client_secret', file);
|
||||
|
||||
const response = await axios.post(`${sheetsPath}/${sheetId}/connect`, formData, {
|
||||
signal: requestOptions?.signal,
|
||||
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
@@ -53,24 +58,50 @@ export const revokeAuthentication = async (): Promise<{ authenticated: Authentic
|
||||
export const previewRundown = async (
|
||||
sheetId: string,
|
||||
options: ImportMap,
|
||||
requestOptions?: RequestOptions,
|
||||
): Promise<{
|
||||
rundown: Rundown;
|
||||
customFields: CustomFields;
|
||||
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;
|
||||
};
|
||||
|
||||
export const getWorksheetNames = async (sheetId: string): Promise<string[]> => {
|
||||
const response: AxiosResponse<string[]> = await axios.post(`${sheetsPath}/${sheetId}/worksheets`);
|
||||
export const getWorksheetNames = async (sheetId: string, requestOptions?: RequestOptions): Promise<string[]> => {
|
||||
const response: AxiosResponse<string[]> = await axios.post(
|
||||
`${sheetsPath}/${sheetId}/worksheets`,
|
||||
undefined,
|
||||
{
|
||||
signal: requestOptions?.signal,
|
||||
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP request to upload the rundown to a google sheet
|
||||
*/
|
||||
export const uploadRundown = async (sheetId: string, options: ImportMap): Promise<void> => {
|
||||
const response = await axios.post(`${sheetsPath}/${sheetId}/write`, { options });
|
||||
export const uploadRundown = async (
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -2,14 +2,15 @@ import axios from 'axios';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve all presets
|
||||
*/
|
||||
export async function getUrlPresets(): Promise<URLPreset[]> {
|
||||
const res = await axios.get(urlPresetsPath);
|
||||
export async function getUrlPresets(options?: RequestOptions): Promise<URLPreset[]> {
|
||||
const res = await axios.get(urlPresetsPath, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import axios from 'axios';
|
||||
import type { ViewSettings } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
|
||||
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
|
||||
|
||||
@@ -9,8 +10,8 @@ const viewSettingsPath = `${apiEntryUrl}/view-settings`;
|
||||
* HTTP request to fetch view settings
|
||||
* @returns
|
||||
*/
|
||||
export async function getViewSettings(): Promise<ViewSettings> {
|
||||
const res = await axios.get(viewSettingsPath);
|
||||
export async function getViewSettings(options?: RequestOptions): Promise<ViewSettings> {
|
||||
const res = await axios.get(viewSettingsPath, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ $thumb-color-hover: $white-60;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $blue-500;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbar {
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function useAppVersion() {
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: APP_VERSION,
|
||||
queryFn: getLatestVersion,
|
||||
queryFn: ({ signal }) => getLatestVersion({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
|
||||
@@ -8,12 +8,9 @@ import { automationPlaceholderSettings } from '../models/AutomationSettings';
|
||||
export default function useAutomationSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: AUTOMATION,
|
||||
queryFn: getAutomationSettings,
|
||||
queryFn: ({ signal }) => getAutomationSettings({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt: number) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
|
||||
|
||||
@@ -10,12 +10,9 @@ const placeholder: CustomFields = {};
|
||||
export default function useCustomFields() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: CUSTOM_FIELDS,
|
||||
queryFn: getCustomFields,
|
||||
queryFn: ({ signal }) => getCustomFields({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data: data ?? placeholder, status, isFetching, isError, refetch };
|
||||
|
||||
@@ -8,7 +8,7 @@ import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
export function useCustomTranslation() {
|
||||
const { data, status, refetch } = useQuery({
|
||||
queryKey: TRANSLATION,
|
||||
queryFn: getUserTranslation,
|
||||
queryFn: ({ signal }) => getUserTranslation({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
|
||||
@@ -9,12 +9,9 @@ import { ontimePlaceholderInfo } from '../models/Info';
|
||||
export default function useInfo() {
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<GetInfo>({
|
||||
queryKey: APP_INFO,
|
||||
queryFn: getInfo,
|
||||
queryFn: ({ signal }) => getInfo({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data: data ?? ontimePlaceholderInfo, status, isError, refetch, isFetching };
|
||||
|
||||
@@ -8,7 +8,7 @@ import { projectDataPlaceholder } from '../models/ProjectData';
|
||||
export default function useProjectData() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: PROJECT_DATA,
|
||||
queryFn: getProjectData,
|
||||
queryFn: ({ signal }) => getProjectData({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
|
||||
@@ -14,12 +14,9 @@ const placeholderProjectList: ProjectFileListResponse = {
|
||||
function useProjectList() {
|
||||
const { data, status, refetch } = useQuery({
|
||||
queryKey: PROJECT_LIST,
|
||||
queryFn: getProjects,
|
||||
queryFn: ({ signal }) => getProjects({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt: number) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
return { data: data ?? placeholderProjectList, status, refetch };
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createRundown, deleteRundown, duplicateRundown, fetchProjectRundownList
|
||||
export function useProjectRundowns() {
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<ProjectRundownsList>({
|
||||
queryKey: PROJECT_RUNDOWNS,
|
||||
queryFn: fetchProjectRundownList,
|
||||
queryFn: ({ signal }) => fetchProjectRundownList({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
|
||||
@@ -8,11 +8,8 @@ import { fetchReport } from '../api/report';
|
||||
export default function useReport() {
|
||||
const { data, refetch } = useQuery<OntimeReport>({
|
||||
queryKey: REPORT,
|
||||
queryFn: fetchReport,
|
||||
queryFn: ({ signal }) => fetchReport({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
networkMode: 'always',
|
||||
staleTime: MILLIS_PER_HOUR,
|
||||
});
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ const cachedRundownPlaceholder: Rundown = {
|
||||
export default function useRundown() {
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
|
||||
queryKey: RUNDOWN,
|
||||
queryFn: fetchCurrentRundown,
|
||||
queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ontimePlaceholderSettings } from '../models/OntimeSettings';
|
||||
export default function useSettings() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: APP_SETTINGS,
|
||||
queryFn: getSettings,
|
||||
queryFn: ({ signal }) => getSettings({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
select: (data) => {
|
||||
const unobfuscated = { ...data };
|
||||
|
||||
@@ -13,7 +13,7 @@ interface FetchProps {
|
||||
export default function useUrlPresets({ skip = false }: FetchProps = {}) {
|
||||
const { data, status, isError, refetch } = useQuery({
|
||||
queryKey: URL_PRESETS,
|
||||
queryFn: getUrlPresets,
|
||||
queryFn: ({ signal }) => getUrlPresets({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
enabled: !skip,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { viewsSettingsPlaceholder } from '../models/ViewSettings.type';
|
||||
export default function useViewSettings() {
|
||||
const { data, status } = useQuery({
|
||||
queryKey: VIEW_SETTINGS,
|
||||
queryFn: getViewSettings,
|
||||
queryFn: ({ signal }) => getViewSettings({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
staleTime: MILLIS_PER_HOUR,
|
||||
});
|
||||
|
||||
@@ -6,15 +6,31 @@ import {
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
MaybeString,
|
||||
OntimeDelay,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
OntimeGroup,
|
||||
OntimeMilestone,
|
||||
PatchWithId,
|
||||
Rundown,
|
||||
SupportedEntry,
|
||||
TimeField,
|
||||
TimeStrategy,
|
||||
TransientEventPayload,
|
||||
} 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 { RUNDOWN } from '../api/constants';
|
||||
@@ -85,9 +101,64 @@ export const useEntryActions = () => {
|
||||
* @private
|
||||
*/
|
||||
const { mutateAsync: addEntryMutation } = useMutation({
|
||||
mutationFn: ([rundownId, entry]: Parameters<typeof postAddEntry>) => postAddEntry(rundownId, entry),
|
||||
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
|
||||
mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry),
|
||||
onMutate: async ([_rundownId, entry]) => {
|
||||
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');
|
||||
}
|
||||
|
||||
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
|
||||
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 {
|
||||
await addEntryMutation([rundownId, newEntry]);
|
||||
} catch (error) {
|
||||
@@ -905,3 +976,29 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,19 +202,19 @@ export const useGroupTimerOverView = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
mode: state.offset.mode,
|
||||
groupExpectedEnd: state.offset.expectedGroupEnd,
|
||||
// we can force these numbers to 0 fo this use case to avoid null checks
|
||||
// we can force these numbers to 0 for this use case to avoid null checks
|
||||
actualGroupStart: state.rundown.actualGroupStart ?? 0,
|
||||
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||
currentDay: state.rundown.currentDay ?? 0,
|
||||
playback: state.timer.playback,
|
||||
}));
|
||||
|
||||
export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
mode: state.offset.mode,
|
||||
// we can force these numbers to 0 fo this use case to avoid null checks
|
||||
// we can force these numbers to 0 for this use case to avoid null checks
|
||||
actualStart: state.rundown.actualStart ?? 0,
|
||||
plannedStart: state.rundown.plannedStart ?? 0,
|
||||
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||
currentDay: state.rundown.currentDay ?? 0,
|
||||
playback: state.timer.playback,
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Settings } from 'ontime-types';
|
||||
|
||||
export const ontimePlaceholderSettings: Settings = {
|
||||
version: '4.0.0',
|
||||
serverPort: 4001,
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
|
||||
import { isOntimeCloud } from '../externals';
|
||||
@@ -8,10 +9,15 @@ export const ontimeQueryClient = new QueryClient({
|
||||
queries: {
|
||||
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
|
||||
networkMode: 'always',
|
||||
networkMode: isOntimeCloud ? 'online' : 'always',
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
retry: (failureCount, error) => {
|
||||
if (axios.isCancel(error)) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < 5;
|
||||
},
|
||||
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 5000),
|
||||
},
|
||||
mutations: {
|
||||
/**
|
||||
@@ -21,6 +27,7 @@ export const ontimeQueryClient = new QueryClient({
|
||||
* - use 'online' for clients that are connected to the cloud
|
||||
*/
|
||||
networkMode: isOntimeCloud ? 'online' : 'always',
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -82,6 +82,57 @@ describe('getRouteFromPreset()', () => {
|
||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cuesheet presets', () => {
|
||||
const cuesheetPreset: URLPreset[] = [
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'cuesheet-4685d6',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
options: {
|
||||
read: 'full',
|
||||
write: '-',
|
||||
},
|
||||
},
|
||||
];
|
||||
const cuesheetPresetWithoutOptions: URLPreset[] = [
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'cuesheet-basic',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
},
|
||||
];
|
||||
const cuesheetPresetWithNavLock: URLPreset[] = [
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'cuesheet-locked',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: 'n=1',
|
||||
},
|
||||
];
|
||||
|
||||
it('keeps cuesheet aliases masked when permissions are stored in preset options', () => {
|
||||
const location = resolvePath('/cuesheet-4685d6');
|
||||
expect(getRouteFromPreset(location, cuesheetPreset)).toBe('preset/cuesheet-4685d6');
|
||||
});
|
||||
|
||||
it('preserves feature params when redirecting masked cuesheet aliases', () => {
|
||||
const location = resolvePath('/cuesheet-4685d6?n=1&token=123');
|
||||
expect(getRouteFromPreset(location, cuesheetPreset)).toBe('preset/cuesheet-4685d6?n=1&token=123');
|
||||
});
|
||||
|
||||
it('keeps cuesheet aliases masked even when preset options are absent', () => {
|
||||
const location = resolvePath('/cuesheet-basic');
|
||||
expect(getRouteFromPreset(location, cuesheetPresetWithoutOptions)).toBe('preset/cuesheet-basic');
|
||||
});
|
||||
|
||||
it('applies navigation lock from preset search params when alias is opened', () => {
|
||||
const location = resolvePath('/cuesheet-locked');
|
||||
expect(getRouteFromPreset(location, cuesheetPresetWithNavLock)).toBe('preset/cuesheet-locked?n=1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePathFromPreset()', () => {
|
||||
@@ -115,6 +166,14 @@ describe('arePathsEquivalent()', () => {
|
||||
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?test=b')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('distinguishes preset paths with different lock or token params', () => {
|
||||
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?n=1')).toBeFalsy();
|
||||
expect(arePathsEquivalent('preset/minimal?n=1', 'preset/minimal?n=1')).toBeTruthy();
|
||||
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?token=abc')).toBeFalsy();
|
||||
expect(arePathsEquivalent('preset/minimal?n=1&token=abc', 'preset/minimal?n=1')).toBeFalsy();
|
||||
expect(arePathsEquivalent('preset/minimal?n=1&token=abc', 'preset/minimal?n=1&token=abc')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('considers edge cases for the url sharing feature', () => {
|
||||
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=b')).toBeFalsy();
|
||||
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=a')).toBeTruthy();
|
||||
|
||||
@@ -38,7 +38,13 @@ import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
|
||||
|
||||
let websocket: WebSocket | 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 reconnectAttempts = 0;
|
||||
@@ -49,7 +55,11 @@ export const connectSocket = () => {
|
||||
const preferredClientName = getClientName();
|
||||
|
||||
websocket.onopen = () => {
|
||||
clearTimeout(reconnectTimeout as NodeJS.Timeout);
|
||||
const isReconnect = hasConnected;
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
reconnectTimeout = null;
|
||||
}
|
||||
hasConnected = true;
|
||||
reconnectAttempts = 0;
|
||||
|
||||
@@ -59,24 +69,38 @@ export const connectSocket = () => {
|
||||
path: window.location.pathname + window.location.search,
|
||||
name: preferredClientName,
|
||||
});
|
||||
invalidateAllCaches(); // assume all data to be stale after a reconnect
|
||||
|
||||
if (isReconnect) {
|
||||
invalidateAllCaches();
|
||||
}
|
||||
setOnlineStatus(true);
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
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(() => {
|
||||
if (reconnectAttempts > 2) {
|
||||
reconnectTimeout = null;
|
||||
if (reconnectAttempts > socketConfig.offlineAttemptsThreshold) {
|
||||
setOnlineStatus(false);
|
||||
}
|
||||
console.warn('WebSocket: attempting reconnect');
|
||||
console.warn(`WebSocket: reconnecting now (#${reconnectAttempts + 1}, waited ${delay}ms)`);
|
||||
if (websocket && websocket.readyState === WebSocket.CLOSED) {
|
||||
reconnectAttempts += 1;
|
||||
connectSocket();
|
||||
}
|
||||
}, reconnectInterval);
|
||||
}, delay);
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
|
||||
@@ -39,11 +39,13 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
|
||||
// NOTE: verify that this resolves correctly in cloud
|
||||
const currentPath = `${location.pathname}${location.search}`.substring(1);
|
||||
const currentURL = getCurrentPath(location);
|
||||
const token = new URLSearchParams(location.search).get('token');
|
||||
const isLocked = location.search.includes('n=1');
|
||||
const locationParams = new URLSearchParams(location.search);
|
||||
const token = locationParams.get('token');
|
||||
|
||||
for (const preset of urlPresets) {
|
||||
if (!preset.enabled) continue;
|
||||
const presetParams = new URLSearchParams(preset.search);
|
||||
const isLocked = locationParams.get('n') === '1' || presetParams.get('n') === '1';
|
||||
/**
|
||||
* If the page is a known alias it would be like
|
||||
* /preset/{alias} <- locked to a preset
|
||||
@@ -53,7 +55,9 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
|
||||
* we need to compare the saved preset to the current path to see if we need to redirect
|
||||
*/
|
||||
if (preset.alias === currentURL || preset.target === currentURL) {
|
||||
const newPath = generatePathFromPreset(preset.target, preset.search, preset.alias, isLocked, token);
|
||||
const newPath = shouldMaskPresetPath(preset)
|
||||
? generateMaskedPathFromPreset(preset.alias, isLocked, token)
|
||||
: generatePathFromPreset(preset.target, preset.search, preset.alias, isLocked, token);
|
||||
/**
|
||||
* if the current path is equivalent to the new path, we return null
|
||||
* this means we will not redirect
|
||||
@@ -114,6 +118,30 @@ export function generatePathFromPreset(
|
||||
return `${path.pathname}?${searchParams}`.substring(1);
|
||||
}
|
||||
|
||||
function generateMaskedPathFromPreset(alias: string, locked: boolean, token: string | null): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (locked) {
|
||||
searchParams.set('n', '1');
|
||||
}
|
||||
|
||||
if (token) {
|
||||
searchParams.set('token', token);
|
||||
}
|
||||
|
||||
const path = `preset/${alias}`;
|
||||
const search = searchParams.toString();
|
||||
if (!search) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return `${path}?${search}`;
|
||||
}
|
||||
|
||||
function shouldMaskPresetPath(preset: URLPreset): boolean {
|
||||
return preset.target === OntimeView.Cuesheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility checks if two paths are equivalent
|
||||
* For preset paths, only compares the path (since params are stored in session)
|
||||
@@ -123,16 +151,19 @@ export function arePathsEquivalent(currentPath: string, newPath: string): boolea
|
||||
const currentUrl = new URL(currentPath, document.location.origin);
|
||||
const newUrl = new URL(newPath, document.location.origin);
|
||||
|
||||
// For preset paths, only compare the path
|
||||
if (currentUrl.pathname.startsWith('/preset/') || newUrl.pathname.startsWith('/preset/')) {
|
||||
return currentUrl.pathname === newUrl.pathname;
|
||||
}
|
||||
|
||||
// For regular paths, compare path and search params (ignoring token)
|
||||
if (currentUrl.pathname !== newUrl.pathname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For preset paths, only n and token are meaningful — ignore everything else
|
||||
if (currentUrl.pathname.startsWith('/preset/')) {
|
||||
return (
|
||||
currentUrl.searchParams.get('n') === newUrl.searchParams.get('n') &&
|
||||
currentUrl.searchParams.get('token') === newUrl.searchParams.get('token')
|
||||
);
|
||||
}
|
||||
|
||||
// For regular paths, compare all search params except n and token
|
||||
currentUrl.searchParams.delete('token');
|
||||
currentUrl.searchParams.delete('n');
|
||||
newUrl.searchParams.delete('token');
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ ul {
|
||||
|
||||
.tabs {
|
||||
width: min(30vw, 300px);
|
||||
flex: 0 0 min(30vw, 300px);
|
||||
min-width: min(30vw, 300px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
|
||||
+10
@@ -54,6 +54,13 @@ const eventStaticPropertiesNext = [
|
||||
'{{eventNext.delay}}',
|
||||
];
|
||||
|
||||
const staticAuxProperties = (index: 1 | 2 | 3) => [
|
||||
`{{auxtimer${index}.current}}`,
|
||||
`{{auxtimer${index}.duration}}`,
|
||||
`{{auxtimer${index}.playback}}`,
|
||||
`{{auxtimer${index}.direction}}`,
|
||||
];
|
||||
|
||||
/**
|
||||
* Creates a it of possible autocomplete suggestions
|
||||
* Based on RuntimeState
|
||||
@@ -68,6 +75,9 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
|
||||
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
|
||||
...eventStaticPropertiesNext,
|
||||
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
|
||||
...staticAuxProperties(1),
|
||||
...staticAuxProperties(2),
|
||||
...staticAuxProperties(3),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -1,5 +1,7 @@
|
||||
import { ImportCustom, ImportMap } from 'ontime-utils';
|
||||
|
||||
import { makeStageKey } from '../../../../../../common/utils/localStorage';
|
||||
|
||||
export type NamedImportMap = typeof namedImportMap;
|
||||
|
||||
// Record of label and import name
|
||||
@@ -63,12 +65,14 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
|
||||
};
|
||||
}
|
||||
|
||||
const importMapKey = makeStageKey('import-map');
|
||||
|
||||
export function persistImportMap(options: NamedImportMap) {
|
||||
localStorage.setItem('import-options', JSON.stringify(options));
|
||||
localStorage.setItem(importMapKey, JSON.stringify(options));
|
||||
}
|
||||
|
||||
function getPersistImportMap(): unknown {
|
||||
const options = localStorage.getItem('import-options');
|
||||
const options = localStorage.getItem(importMapKey);
|
||||
if (!options) {
|
||||
throw new Error('no import options found');
|
||||
}
|
||||
|
||||
@@ -7,12 +7,9 @@ import { postSettings } from '../../../../common/api/settings';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import useSettings from '../../../../common/hooks-query/useSettings';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import GeneralPinInput from './composite/GeneralPinInput';
|
||||
@@ -32,7 +29,6 @@ export default function GeneralSettings() {
|
||||
} = useForm<Settings>({
|
||||
mode: 'onChange',
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
@@ -102,33 +98,6 @@ export default function GeneralSettings() {
|
||||
<Info>Changes to the time format and views language do not affect the editor view</Info>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Ontime server port'
|
||||
description={
|
||||
isOntimeCloud
|
||||
? 'Server port disabled for Ontime Cloud'
|
||||
: 'Port ontime server listens in. Defaults to 4001 (needs app restart)'
|
||||
}
|
||||
error={errors.serverPort?.message}
|
||||
/>
|
||||
<Input
|
||||
id='serverPort'
|
||||
type='number'
|
||||
maxLength={5}
|
||||
style={{ width: '75px' }}
|
||||
disabled={isOntimeCloud}
|
||||
{...register('serverPort', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||
pattern: {
|
||||
value: isOnlyNumbers,
|
||||
message: 'Value should be numeric',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Editor pin code'
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { PortInfo } from 'ontime-types';
|
||||
|
||||
import { getServerPort, postServerPort } from '../../../../common/api/settings';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
interface ServerPortForm {
|
||||
serverPort: number;
|
||||
}
|
||||
|
||||
export default function ServerPortSettings() {
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
setError,
|
||||
formState: { isSubmitting, isDirty, isValid, errors },
|
||||
} = useForm<ServerPortForm>({
|
||||
mode: 'onChange',
|
||||
defaultValues: { serverPort: 4001 },
|
||||
});
|
||||
|
||||
const [pendingRestart, setPendingRestart] = useState<boolean>(false);
|
||||
|
||||
const setPort = useCallback((info: PortInfo) => {
|
||||
reset({ serverPort: info.port });
|
||||
setPendingRestart(info.pendingRestart);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getServerPort()
|
||||
.then(setPort)
|
||||
.catch(() => setError('root', { message: 'Failed to load server port' }));
|
||||
}, [reset, setError, setPort]);
|
||||
|
||||
const onSubmit = async (formData: ServerPortForm) => {
|
||||
if (formData.serverPort < 1024 || formData.serverPort > 65535) {
|
||||
setError('serverPort', { message: 'Port must be within range 1024 - 65535' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await postServerPort(formData.serverPort);
|
||||
setPort(await getServerPort());
|
||||
} catch (error) {
|
||||
setError('root', { message: maybeAxiosError(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = async () => {
|
||||
try {
|
||||
setPort(await getServerPort());
|
||||
} catch (error) {
|
||||
setError('root', { message: 'Failed to load server port' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section
|
||||
as='form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||
id='server-port-settings'
|
||||
>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Server port
|
||||
<Panel.InlineElements>
|
||||
{pendingRestart && <Tag>A port change is pending and will happen on the next restart</Tag>}
|
||||
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
form='server-port-settings'
|
||||
name='server-port-settings-submit'
|
||||
loading={isSubmitting}
|
||||
disabled={!isDirty || !isValid || isSubmitting}
|
||||
variant='primary'
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Ontime server port'
|
||||
description='Port ontime server listens in. Defaults to 4001 (needs app restart)'
|
||||
error={errors.serverPort?.message}
|
||||
/>
|
||||
<Input
|
||||
id='serverPort'
|
||||
type='number'
|
||||
maxLength={5}
|
||||
style={{ width: '75px' }}
|
||||
{...register('serverPort', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||
pattern: {
|
||||
value: isOnlyNumbers,
|
||||
message: 'Value should be numeric',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { isDocker } from '../../../../externals';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import GeneralSettings from './GeneralSettings';
|
||||
import ProjectData from './ProjectData';
|
||||
import ServerPortSettings from './ServerPortSettings';
|
||||
import ViewSettings from './ViewSettings';
|
||||
|
||||
export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
|
||||
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
|
||||
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
|
||||
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
|
||||
|
||||
return (
|
||||
@@ -23,6 +26,11 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
<div ref={viewRef}>
|
||||
<ViewSettings />
|
||||
</div>
|
||||
{!isDocker && (
|
||||
<div ref={portRef}>
|
||||
<ServerPortSettings />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ export default function ViewSettings() {
|
||||
formState: { isSubmitting, isDirty, errors },
|
||||
} = useForm<ViewSettingsType>({
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
resetOptions: {
|
||||
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 Button from '../../../../../common/components/buttons/Button';
|
||||
@@ -81,7 +81,9 @@ export default function CodeEditorModal({ isOpen, onClose }: CodeEditorModalProp
|
||||
showCloseButton
|
||||
showBackdrop
|
||||
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={
|
||||
<div className={style.column}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import useAppVersion from '../../common/hooks-query/useAppVersion';
|
||||
import { isDocker } from '../../externals';
|
||||
|
||||
export type SettingsOption = {
|
||||
id: string;
|
||||
@@ -18,6 +19,7 @@ const staticOptions = [
|
||||
{ id: 'settings__data', label: 'Project data' },
|
||||
{ id: 'settings__general', label: 'General settings' },
|
||||
{ id: 'settings__view', label: 'View settings' },
|
||||
{ id: 'settings__port', label: 'Server Port' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -102,6 +104,14 @@ export function useAppSettingsMenu() {
|
||||
() =>
|
||||
staticOptions.map((option) => ({
|
||||
...option,
|
||||
// if we are in docker don't show the port option
|
||||
secondary:
|
||||
'secondary' in option
|
||||
? isDocker && option.id === 'settings'
|
||||
? [...option.secondary.filter(({ id }) => id !== 'settings__port')]
|
||||
: [...option.secondary]
|
||||
: undefined,
|
||||
// if there is an update then highlight the about setting
|
||||
highlight: option.id === 'about' && data.hasUpdates ? 'New version available' : undefined,
|
||||
})),
|
||||
[data],
|
||||
|
||||
@@ -42,7 +42,11 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
|
||||
<div className={style.indicatorNegative} data-active={isOvertime} />
|
||||
<Tooltip text={addedTimeLabel} render={<div />} className={style.indicatorDelay} data-active={hasAddedTime} />
|
||||
</div>
|
||||
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} phase={timer.phase} />
|
||||
<TimerDisplay
|
||||
className={style.timerDisplay}
|
||||
time={isWaiting ? timer.secondaryTimer : timer.current}
|
||||
phase={timer.phase}
|
||||
/>
|
||||
<div className={style.status}>
|
||||
{isWaiting ? (
|
||||
<span className={style.rolltag}>Roll: Countdown to start</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CSSProperties, memo, RefObject, SyntheticEvent } from 'react';
|
||||
import { Day } from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
|
||||
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
|
||||
@@ -20,7 +21,7 @@ interface OperatorEventProps {
|
||||
timeStart: number;
|
||||
duration: number;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
dayOffset: Day;
|
||||
isLinkedToLoaded: boolean;
|
||||
isSelected: boolean;
|
||||
isPast: boolean;
|
||||
@@ -138,7 +139,7 @@ interface OperatorEventScheduleProps {
|
||||
isPast: boolean;
|
||||
isSelected: boolean;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
dayOffset: Day;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
@@ -173,7 +174,7 @@ function OperatorEventSchedule({
|
||||
interface TimeUntilProps {
|
||||
timeStart: number;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
dayOffset: Day;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
|
||||
@@ -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 { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { closestCenter, DndContext } from '@dnd-kit/core';
|
||||
@@ -26,14 +26,13 @@ import RundownGroup from './rundown-group/RundownGroup';
|
||||
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
|
||||
import { filterVisibleEntries, makeSortableList } from './rundown.utils';
|
||||
import RundownEmpty from './RundownEmpty';
|
||||
import RundownEntry from './RundownEntry';
|
||||
import { useCollapsedGroups } from './useCollapsedGroups';
|
||||
import { useEditorFollowMode } from './useEditorFollowMode';
|
||||
import { useEventSelection } from './useEventSelection';
|
||||
|
||||
import style from './Rundown.module.scss';
|
||||
|
||||
const RundownEntry = lazy(() => import('./RundownEntry'));
|
||||
|
||||
interface RundownProps {
|
||||
entries: RundownType['entries'];
|
||||
id: RundownType['id'];
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { TbFlagFilled } from 'react-icons/tb';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { EndAction, EntryId, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { Day, EndAction, EntryId, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { isPlaybackActive } from 'ontime-utils';
|
||||
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
@@ -55,7 +55,7 @@ interface RundownEventProps {
|
||||
isRolling: boolean;
|
||||
gap: number;
|
||||
isNextDay: boolean;
|
||||
dayOffset: number;
|
||||
dayOffset: Day;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
hasTriggers: boolean;
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
IoTime,
|
||||
} from 'react-icons/io5';
|
||||
import { LuArrowDownToLine } from 'react-icons/lu';
|
||||
import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { Day, EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
|
||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
@@ -44,7 +44,7 @@ interface RundownEventInnerProps {
|
||||
loaded: boolean;
|
||||
playback?: Playback;
|
||||
isRolling: boolean;
|
||||
dayOffset: number;
|
||||
dayOffset: Day;
|
||||
isPast: boolean;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { IoCheckmarkCircle } from 'react-icons/io5';
|
||||
import { Day } from 'ontime-types';
|
||||
import { isPlaybackActive, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString } from 'ontime-utils';
|
||||
|
||||
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
||||
@@ -14,7 +15,7 @@ interface RundownEventChipProps {
|
||||
id: string;
|
||||
timeStart: number;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
dayOffset: Day;
|
||||
isPast: boolean;
|
||||
isLoaded: boolean;
|
||||
className: string;
|
||||
@@ -68,7 +69,7 @@ export default function RundownEventChip({
|
||||
interface EventUntilProps {
|
||||
timeStart: number;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
dayOffset: Day;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
|
||||
@@ -1,49 +1,19 @@
|
||||
import { memo, use, useEffect, useMemo } from 'react';
|
||||
import { useSessionStorage } from '@mantine/hooks';
|
||||
import { memo, use, useMemo } from 'react';
|
||||
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import { PresetContext } from '../../common/context/PresetContext';
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import { sessionScope } from '../../externals';
|
||||
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
||||
|
||||
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
|
||||
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
|
||||
import CuesheetTable from './cuesheet-table/CuesheetTable';
|
||||
import { useCuesheetPermissions } from './useTablePermissions';
|
||||
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
|
||||
|
||||
export default memo(CuesheetTableWrapper);
|
||||
function CuesheetTableWrapper() {
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
||||
const preset = use(PresetContext);
|
||||
|
||||
// set permissions based on preset
|
||||
useEffect(() => {
|
||||
if (preset) {
|
||||
const fullWrite = preset.options?.write === 'full';
|
||||
setPermissions({
|
||||
canChangeMode: preset.options?.write !== '-',
|
||||
canCreateEntries: fullWrite,
|
||||
canEditEntries: fullWrite,
|
||||
canFlag: fullWrite || Boolean(preset.options?.write.includes('flag')),
|
||||
canShare: false, // TODO: should be sessionScope === 'rw' when we have granular scopes
|
||||
});
|
||||
} else {
|
||||
setPermissions({
|
||||
canChangeMode: true,
|
||||
canCreateEntries: true,
|
||||
canEditEntries: true,
|
||||
canFlag: true,
|
||||
canShare: sessionScope === 'rw',
|
||||
});
|
||||
}
|
||||
}, [preset, setPermissions]);
|
||||
|
||||
const [cuesheetMode] = useSessionStorage({
|
||||
key: preset ? `${preset.alias}${sessionKeys.cuesheetMode}` : sessionKeys.cuesheetMode,
|
||||
defaultValue: preset ? AppMode.Run : AppMode.Edit,
|
||||
});
|
||||
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset);
|
||||
|
||||
const columns = useMemo(
|
||||
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
|
||||
@@ -54,7 +24,16 @@ function CuesheetTableWrapper() {
|
||||
|
||||
return (
|
||||
<CuesheetDnd columns={columns}>
|
||||
{isLoading ? <EmptyPage text='Loading...' /> : <CuesheetTable columns={columns} cuesheetMode={cuesheetMode} />}
|
||||
{isLoading ? (
|
||||
<EmptyPage text='Loading...' />
|
||||
) : (
|
||||
<CuesheetTable
|
||||
columns={columns}
|
||||
cuesheetMode={cuesheetMode}
|
||||
tableRoot='cuesheet'
|
||||
setCuesheetMode={setCuesheetMode}
|
||||
/>
|
||||
)}
|
||||
</CuesheetDnd>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { OntimeView, URLPreset } from 'ontime-types';
|
||||
|
||||
import { AppMode } from '../../../ontimeConfig';
|
||||
import { getCuesheetColumnAccessPolicy, getCuesheetPermissionsPolicy } from '../cuesheet.policies';
|
||||
|
||||
describe('getCuesheetPermissionsPolicy()', () => {
|
||||
test('returns full permissions when there is no preset', () => {
|
||||
expect(getCuesheetPermissionsPolicy(undefined, true)).toEqual({
|
||||
canChangeMode: true,
|
||||
canCreateEntries: true,
|
||||
canEditEntries: true,
|
||||
canFlag: true,
|
||||
canShare: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns run-only permissions when write is disabled', () => {
|
||||
const preset: URLPreset = {
|
||||
enabled: true,
|
||||
alias: 'cuesheet-read-only',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
options: {
|
||||
read: 'full',
|
||||
write: '-',
|
||||
},
|
||||
};
|
||||
|
||||
expect(getCuesheetPermissionsPolicy(preset, true)).toEqual({
|
||||
canChangeMode: false,
|
||||
canCreateEntries: false,
|
||||
canEditEntries: false,
|
||||
canFlag: false,
|
||||
canShare: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('allows flag changes when write includes flag only', () => {
|
||||
const preset: URLPreset = {
|
||||
enabled: true,
|
||||
alias: 'cuesheet-flag',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
options: {
|
||||
read: 'full',
|
||||
write: 'flag',
|
||||
},
|
||||
};
|
||||
|
||||
expect(getCuesheetPermissionsPolicy(preset, true)).toEqual({
|
||||
canChangeMode: true,
|
||||
canCreateEntries: false,
|
||||
canEditEntries: false,
|
||||
canFlag: true,
|
||||
canShare: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('defaults to full read and write when cuesheet options are absent', () => {
|
||||
const preset: URLPreset = {
|
||||
enabled: true,
|
||||
alias: 'cuesheet-default',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
};
|
||||
|
||||
const policy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit);
|
||||
|
||||
expect(policy.canRead('title')).toBe(true);
|
||||
expect(policy.canWrite('title')).toBe(true);
|
||||
});
|
||||
|
||||
test('column access honors granular permissions and mode', () => {
|
||||
const preset: URLPreset = {
|
||||
enabled: true,
|
||||
alias: 'cuesheet-granular',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
options: {
|
||||
read: 'cue,title',
|
||||
write: 'title',
|
||||
},
|
||||
};
|
||||
|
||||
const editPolicy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit);
|
||||
const runPolicy = getCuesheetColumnAccessPolicy(preset, AppMode.Run);
|
||||
|
||||
expect(editPolicy.canRead('cue')).toBe(true);
|
||||
expect(editPolicy.canRead('duration')).toBe(false);
|
||||
expect(editPolicy.canWrite('title')).toBe(true);
|
||||
expect(editPolicy.canWrite('cue')).toBe(false);
|
||||
expect(runPolicy.canWrite('title')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -34,13 +34,24 @@ import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumn
|
||||
|
||||
import style from './CuesheetTable.module.scss';
|
||||
|
||||
interface CuesheetTableProps {
|
||||
type CuesheetTableBaseProps = {
|
||||
columns: ColumnDef<ExtendedEntry>[];
|
||||
cuesheetMode: AppMode;
|
||||
tableRoot?: 'editor' | 'cuesheet';
|
||||
}
|
||||
};
|
||||
|
||||
export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cuesheet' }: CuesheetTableProps) {
|
||||
type EditorCuesheetTableProps = CuesheetTableBaseProps & {
|
||||
tableRoot: 'editor';
|
||||
setCuesheetMode?: undefined;
|
||||
};
|
||||
|
||||
type ViewCuesheetTableProps = CuesheetTableBaseProps & {
|
||||
tableRoot: 'cuesheet';
|
||||
setCuesheetMode: (mode: AppMode) => void;
|
||||
};
|
||||
|
||||
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
|
||||
|
||||
export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCuesheetMode }: CuesheetTableProps) {
|
||||
const { data, status } = useFlatRundownWithMetadata();
|
||||
const { updateEntry, updateTimer } = useEntryActionsContext();
|
||||
|
||||
@@ -206,17 +217,25 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
|
||||
return <EmptyPage text='Loading...' />;
|
||||
}
|
||||
|
||||
// control components need different implementations for handling permissions
|
||||
const TableRootSettings = tableRoot === 'editor' ? EditorTableSettings : CuesheetTableSettings;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRootSettings
|
||||
columns={allLeafColumns}
|
||||
handleResetResizing={resetColumnResizing}
|
||||
handleResetReordering={resetColumnOrder}
|
||||
handleClearToggles={setAllVisible}
|
||||
/>
|
||||
{tableRoot === 'editor' ? (
|
||||
<EditorTableSettings
|
||||
columns={allLeafColumns}
|
||||
handleResetResizing={resetColumnResizing}
|
||||
handleResetReordering={resetColumnOrder}
|
||||
handleClearToggles={setAllVisible}
|
||||
/>
|
||||
) : (
|
||||
<CuesheetTableSettings
|
||||
columns={allLeafColumns}
|
||||
cuesheetMode={cuesheetMode}
|
||||
setCuesheetMode={setCuesheetMode}
|
||||
handleResetResizing={resetColumnResizing}
|
||||
handleResetReordering={resetColumnOrder}
|
||||
handleClearToggles={setAllVisible}
|
||||
/>
|
||||
)}
|
||||
<TableVirtuoso
|
||||
ref={virtuosoRef}
|
||||
data={data}
|
||||
|
||||
+2
-9
@@ -7,6 +7,7 @@ import DelayIndicator from '../../../../common/components/delay-indicator/DelayI
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
|
||||
|
||||
import DurationInput from './DurationInput';
|
||||
import EditableImage from './EditableImage';
|
||||
@@ -257,15 +258,7 @@ export function makeCuesheetColumns(
|
||||
preset: URLPreset | undefined,
|
||||
): ColumnDef<ExtendedEntry>[] {
|
||||
const columnsDef: ColumnDef<ExtendedEntry>[] = [];
|
||||
const modeAllowsWrite = cuesheetMode === AppMode.Edit;
|
||||
const fullRead = preset ? preset.options?.read === 'full' : true;
|
||||
const fullWrite = preset ? preset.options?.write === 'full' : true;
|
||||
const canWriteKeys = preset?.options?.write ? new Set(preset.options.write.split(',')) : new Set<string>();
|
||||
const canReadKeys = preset?.options?.read ? new Set(preset.options.read.split(',')) : new Set<string>();
|
||||
|
||||
// helpers to check read/write for a given key
|
||||
const canRead = (key: string) => fullRead || canReadKeys.has(key);
|
||||
const canWrite = (key: string) => modeAllowsWrite && (fullWrite || canWriteKeys.has(key));
|
||||
const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode);
|
||||
|
||||
if (canRead('flag')) {
|
||||
columnsDef.push({
|
||||
|
||||
+21
-18
@@ -1,20 +1,18 @@
|
||||
import { ReactNode, use } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5';
|
||||
import { Popover } from '@base-ui/react/popover';
|
||||
import { Toggle } from '@base-ui/react/toggle';
|
||||
import { ToggleGroup } from '@base-ui/react/toggle-group';
|
||||
import { Toolbar } from '@base-ui/react/toolbar';
|
||||
import { useSessionStorage } from '@mantine/hooks';
|
||||
import type { Column } from '@tanstack/react-table';
|
||||
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||
import PopoverContents from '../../../../common/components/popover/Popover';
|
||||
import { PresetContext } from '../../../../common/context/PresetContext';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { AppMode, sessionKeys } from '../../../../ontimeConfig';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { CuesheetOptions, usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||
import { useCuesheetPermissions } from '../../useTablePermissions';
|
||||
|
||||
@@ -24,6 +22,8 @@ import style from './CuesheetTableSettings.module.scss';
|
||||
|
||||
interface CuesheetTableSettingsProps {
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
cuesheetMode: AppMode;
|
||||
setCuesheetMode: (mode: AppMode) => void;
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
handleClearToggles: () => void;
|
||||
@@ -42,19 +42,16 @@ export interface ColumnSettingsProps {
|
||||
|
||||
export default function CuesheetTableSettings({
|
||||
columns,
|
||||
cuesheetMode,
|
||||
setCuesheetMode,
|
||||
handleResetResizing,
|
||||
handleResetReordering,
|
||||
handleClearToggles,
|
||||
}: CuesheetTableSettingsProps) {
|
||||
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode);
|
||||
const canShare = useCuesheetPermissions((state) => state.canShare);
|
||||
const preset = use(PresetContext);
|
||||
const options = usePersistedCuesheetOptions();
|
||||
|
||||
const [cuesheetMode, setCuesheetMode] = useSessionStorage({
|
||||
key: preset ? `${preset.alias}${sessionKeys.cuesheetMode}` : sessionKeys.cuesheetMode,
|
||||
defaultValue: preset ? AppMode.Run : AppMode.Edit,
|
||||
});
|
||||
|
||||
const toggleCuesheetMode = (mode: AppMode[]) => {
|
||||
// we need to stop user from deselecting a mode
|
||||
const newValue = mode.at(0);
|
||||
@@ -71,14 +68,20 @@ export default function CuesheetTableSettings({
|
||||
handleResetReordering={handleResetReordering}
|
||||
handleClearToggles={handleClearToggles}
|
||||
/>
|
||||
<ToggleGroup value={[cuesheetMode]} onValueChange={toggleCuesheetMode} className={cx([style.group, style.apart])}>
|
||||
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
|
||||
Run
|
||||
</Toolbar.Button>
|
||||
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
|
||||
Edit
|
||||
</Toolbar.Button>
|
||||
</ToggleGroup>
|
||||
{canChangeMode && (
|
||||
<ToggleGroup
|
||||
value={[cuesheetMode]}
|
||||
onValueChange={toggleCuesheetMode}
|
||||
className={cx([style.group, style.apart])}
|
||||
>
|
||||
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
|
||||
Run
|
||||
</Toolbar.Button>
|
||||
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
|
||||
Edit
|
||||
</Toolbar.Button>
|
||||
</ToggleGroup>
|
||||
)}
|
||||
|
||||
{canShare && (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { URLPreset } from 'ontime-types';
|
||||
|
||||
import { AppMode } from '../../ontimeConfig';
|
||||
|
||||
import type { CuesheetPermissions } from './useTablePermissions';
|
||||
|
||||
function getPermissionKeys(permission: string | undefined): Set<string> {
|
||||
return permission ? new Set(permission.split(',')) : new Set<string>();
|
||||
}
|
||||
|
||||
function getCuesheetPermissions(readPermission: string | undefined, writePermission: string | undefined) {
|
||||
const readKeys = getPermissionKeys(readPermission);
|
||||
const writeKeys = getPermissionKeys(writePermission);
|
||||
const fullRead = readPermission == null || readPermission === 'full';
|
||||
const fullWrite = writePermission == null || writePermission === 'full';
|
||||
|
||||
return {
|
||||
writePermission,
|
||||
readKeys,
|
||||
writeKeys,
|
||||
fullRead,
|
||||
fullWrite,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCuesheetPermissionsPolicy(
|
||||
preset: URLPreset | undefined,
|
||||
canShareInSession: boolean,
|
||||
): CuesheetPermissions {
|
||||
if (!preset) {
|
||||
return {
|
||||
canChangeMode: true,
|
||||
canCreateEntries: true,
|
||||
canEditEntries: true,
|
||||
canFlag: true,
|
||||
canShare: canShareInSession,
|
||||
};
|
||||
}
|
||||
|
||||
const { writePermission, writeKeys, fullWrite } = getCuesheetPermissions(
|
||||
preset?.options?.read,
|
||||
preset?.options?.write,
|
||||
);
|
||||
|
||||
return {
|
||||
canChangeMode: writePermission !== '-',
|
||||
canCreateEntries: fullWrite,
|
||||
canEditEntries: fullWrite,
|
||||
canFlag: fullWrite || writeKeys.has('flag'),
|
||||
canShare: false, // TODO: should be sessionScope === 'rw' when we have granular scopes
|
||||
};
|
||||
}
|
||||
|
||||
export function getCuesheetColumnAccessPolicy(preset: URLPreset | undefined, cuesheetMode: AppMode) {
|
||||
const { readKeys, writeKeys, fullRead, fullWrite } = getCuesheetPermissions(
|
||||
preset?.options?.read,
|
||||
preset?.options?.write,
|
||||
);
|
||||
const modeAllowsWrite = cuesheetMode === AppMode.Edit;
|
||||
|
||||
return {
|
||||
canRead: (key: string) => fullRead || readKeys.has(key),
|
||||
canWrite: (key: string) => modeAllowsWrite && (fullWrite || writeKeys.has(key)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useSessionStorage } from '@mantine/hooks';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
|
||||
import { sessionScope } from '../../externals';
|
||||
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
||||
|
||||
import { getCuesheetPermissionsPolicy } from './cuesheet.policies';
|
||||
import { useCuesheetPermissions } from './useTablePermissions';
|
||||
|
||||
/**
|
||||
* Applies cuesheet permissions to shared state and exposes the effective mode for the UI.
|
||||
*/
|
||||
export function useApplyCuesheetPolicy(preset: URLPreset | undefined): {
|
||||
cuesheetMode: AppMode;
|
||||
setCuesheetMode: (mode: AppMode) => void;
|
||||
} {
|
||||
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
||||
const canShareInSession = sessionScope === 'rw';
|
||||
const permissions = useMemo(
|
||||
() => getCuesheetPermissionsPolicy(preset, canShareInSession),
|
||||
[preset, canShareInSession],
|
||||
);
|
||||
|
||||
const [storedCuesheetMode, setStoredCuesheetMode] = useSessionStorage({
|
||||
key: preset ? `${preset.alias}${sessionKeys.cuesheetMode}` : sessionKeys.cuesheetMode,
|
||||
defaultValue: preset ? AppMode.Run : AppMode.Edit,
|
||||
});
|
||||
|
||||
const cuesheetMode = permissions.canChangeMode ? storedCuesheetMode : AppMode.Run;
|
||||
const setCuesheetMode = useCallback(
|
||||
(mode: AppMode) => {
|
||||
if (!permissions.canChangeMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStoredCuesheetMode(mode);
|
||||
},
|
||||
[permissions.canChangeMode, setStoredCuesheetMode],
|
||||
);
|
||||
|
||||
// Keep the shared permissions store aligned with the active preset policy.
|
||||
useEffect(() => {
|
||||
setPermissions(permissions);
|
||||
}, [permissions, setPermissions]);
|
||||
|
||||
// Force Run mode whenever the policy forbids mode switching.
|
||||
useEffect(() => {
|
||||
if (!permissions.canChangeMode) {
|
||||
setStoredCuesheetMode((mode) => (mode === AppMode.Run ? mode : AppMode.Run));
|
||||
}
|
||||
}, [permissions.canChangeMode, setStoredCuesheetMode]);
|
||||
|
||||
return { cuesheetMode, setCuesheetMode };
|
||||
}
|
||||
@@ -6,9 +6,11 @@ interface CuesheetPermissionsStore {
|
||||
canEditEntries: boolean;
|
||||
canFlag: boolean;
|
||||
canShare: boolean;
|
||||
setPermissions: (permissions: Omit<CuesheetPermissionsStore, 'setPermissions'>) => void;
|
||||
setPermissions: (permissions: CuesheetPermissions) => void;
|
||||
}
|
||||
|
||||
export type CuesheetPermissions = Omit<CuesheetPermissionsStore, 'setPermissions'>;
|
||||
|
||||
export const useCuesheetPermissions = create<CuesheetPermissionsStore>((set) => ({
|
||||
canChangeMode: false,
|
||||
canCreateEntries: false,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
import { Day, OntimeEvent } from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
|
||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||
@@ -78,7 +78,7 @@ function TitleListItem({
|
||||
interface TitleListTimeUntilChipProps {
|
||||
timeStart: number;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
dayOffset: Day;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
isLoaded: boolean;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RefObject } from 'react';
|
||||
import { Day } from 'ontime-types';
|
||||
|
||||
import { useExpectedStartData, useTimer } from '../../common/hooks/useSocket';
|
||||
import { getProgress } from '../../common/utils/getProgress';
|
||||
@@ -20,7 +21,7 @@ interface TimelineEntryProps {
|
||||
left: number;
|
||||
status: ProgressStatus;
|
||||
start: number;
|
||||
dayOffset: number;
|
||||
dayOffset: Day;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
title: string;
|
||||
@@ -119,7 +120,7 @@ export function TimelineEntry({
|
||||
interface TimelineEntryStatusProps {
|
||||
delay: number;
|
||||
start: number;
|
||||
dayOffset: number;
|
||||
dayOffset: Day;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
status: ProgressStatus;
|
||||
|
||||
@@ -181,10 +181,10 @@ export function getCardData(
|
||||
: getPropertyValue(eventNow, secondarySource, entries);
|
||||
|
||||
return {
|
||||
showNow: mainSource !== 'none' || Boolean(nowSecondary),
|
||||
showNow: mainSource !== 'none' && (Boolean(nowMain) || Boolean(nowSecondary)),
|
||||
nowMain,
|
||||
nowSecondary,
|
||||
showNext: mainSource !== 'none' || Boolean(nextSecondary),
|
||||
showNext: mainSource !== 'none' && (Boolean(nextMain) || Boolean(nextSecondary)),
|
||||
nextMain,
|
||||
nextSecondary,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-electron",
|
||||
"version": "4.4.0",
|
||||
"version": "4.5.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/resolver",
|
||||
"version": "4.4.0",
|
||||
"version": "4.5.0",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
"types": "./dist/main.d.ts",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "4.4.0",
|
||||
"version": "4.5.0",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { PlayableEvent, TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
|
||||
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||
import { makeRuntimeStoreData } from '../../../stores/__mocks__/runtimeStore.mocks.js';
|
||||
|
||||
import { deleteAllTriggers, addTrigger, addAutomation } from '../automation.dao.js';
|
||||
import { testConditions, triggerAutomations } from '../automation.service.js';
|
||||
@@ -10,6 +9,7 @@ import * as httpClient from '../clients/http.client.js';
|
||||
|
||||
import { makeOSCAction, makeHTTPAction } from './testUtils.js';
|
||||
import { RuntimeState } from '../../../stores/runtimeState.js';
|
||||
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||
|
||||
beforeAll(() => {
|
||||
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
|
||||
@@ -40,9 +40,20 @@ describe('triggerAction()', () => {
|
||||
let oscSpy = vi.spyOn(oscClient, 'emitOSC');
|
||||
let httpSpy = vi.spyOn(httpClient, 'emitHTTP');
|
||||
|
||||
beforeAll(() => {
|
||||
vi.mock('../../../stores/EventStore.js', () => {
|
||||
// Create a small mock store
|
||||
return {
|
||||
eventStore: {
|
||||
poll: vi.fn().mockImplementation(() => makeRuntimeStoreData()),
|
||||
},
|
||||
};
|
||||
});
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
oscSpy = vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => {});
|
||||
httpSpy = vi.spyOn(httpClient, 'emitHTTP').mockImplementation(() => {});
|
||||
oscSpy = vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => { });
|
||||
httpSpy = vi.spyOn(httpClient, 'emitHTTP').mockImplementation(() => { });
|
||||
|
||||
await deleteAllTriggers();
|
||||
const oscAutomation = await addAutomation({
|
||||
@@ -70,26 +81,25 @@ describe('triggerAction()', () => {
|
||||
});
|
||||
|
||||
it('should trigger automations for a given action', () => {
|
||||
const state = makeRuntimeStateData();
|
||||
triggerAutomations(TimerLifeCycle.onLoad, state);
|
||||
triggerAutomations(TimerLifeCycle.onLoad);
|
||||
expect(oscSpy).toHaveBeenCalledTimes(1);
|
||||
expect(httpSpy).not.toBeCalled();
|
||||
oscSpy.mockReset();
|
||||
httpSpy.mockReset();
|
||||
|
||||
triggerAutomations(TimerLifeCycle.onStart, state);
|
||||
triggerAutomations(TimerLifeCycle.onStart);
|
||||
expect(oscClient.emitOSC).not.toBeCalled();
|
||||
expect(httpSpy).not.toBeCalled();
|
||||
oscSpy.mockReset();
|
||||
httpSpy.mockReset();
|
||||
|
||||
triggerAutomations(TimerLifeCycle.onFinish, state);
|
||||
triggerAutomations(TimerLifeCycle.onFinish);
|
||||
expect(oscSpy).not.toBeCalled();
|
||||
expect(httpSpy).toHaveBeenCalledTimes(1);
|
||||
oscSpy.mockReset();
|
||||
httpSpy.mockReset();
|
||||
|
||||
triggerAutomations(TimerLifeCycle.onStop, state);
|
||||
triggerAutomations(TimerLifeCycle.onStop);
|
||||
expect(oscSpy).not.toBeCalled();
|
||||
expect(httpSpy).not.toBeCalled();
|
||||
});
|
||||
@@ -540,7 +550,7 @@ describe('testConditions()', () => {
|
||||
|
||||
describe('for all filter rule', () => {
|
||||
it('should return true when all filters are true', () => {
|
||||
const mockStore = makeRuntimeStateData({
|
||||
const mockStore = makeRuntimeStoreData({
|
||||
clock: 10,
|
||||
eventNow: makeOntimeEvent({
|
||||
title: 'test',
|
||||
@@ -560,7 +570,7 @@ describe('testConditions()', () => {
|
||||
});
|
||||
|
||||
it('should return false if any filters are false', () => {
|
||||
const mockStore = makeRuntimeStateData({
|
||||
const mockStore = makeRuntimeStoreData({
|
||||
clock: 10,
|
||||
eventNow: makeOntimeEvent({
|
||||
title: 'test',
|
||||
@@ -582,7 +592,7 @@ describe('testConditions()', () => {
|
||||
|
||||
describe('for any filter rule', () => {
|
||||
it('should return true when all filters are true', () => {
|
||||
const mockStore = makeRuntimeStateData({
|
||||
const mockStore = makeRuntimeStoreData({
|
||||
clock: 10,
|
||||
eventNow: makeOntimeEvent({
|
||||
title: 'test',
|
||||
@@ -602,7 +612,7 @@ describe('testConditions()', () => {
|
||||
});
|
||||
|
||||
it('should return true if any filters are true', () => {
|
||||
const mockStore = makeRuntimeStateData({
|
||||
const mockStore = makeRuntimeStoreData({
|
||||
clock: 10,
|
||||
eventNow: makeOntimeEvent({
|
||||
title: 'not-test',
|
||||
@@ -622,7 +632,7 @@ describe('testConditions()', () => {
|
||||
});
|
||||
|
||||
it('should return false if all filters are false', () => {
|
||||
const mockStore = makeRuntimeStateData({
|
||||
const mockStore = makeRuntimeStoreData({
|
||||
clock: 10,
|
||||
eventNow: makeOntimeEvent({ title: 'test' }) as PlayableEvent,
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
isOntimeAction,
|
||||
isOSCOutput,
|
||||
LogOrigin,
|
||||
RuntimeStore,
|
||||
TimerLifeCycle,
|
||||
type AutomationFilter,
|
||||
type AutomationOutput,
|
||||
@@ -10,29 +11,30 @@ import {
|
||||
} from 'ontime-types';
|
||||
import { getPropertyFromPath } from 'ontime-utils';
|
||||
|
||||
import { logger } from '../../classes/Logger.js';
|
||||
import { getState, type RuntimeState } from '../../stores/runtimeState.js';
|
||||
import { isOntimeCloud } from '../../setup/environment.js';
|
||||
|
||||
import { emitOSC } from './clients/osc.client.js';
|
||||
import { emitHTTP } from './clients/http.client.js';
|
||||
import { getAutomationsEnabled, getAutomations, getAutomationTriggers } from './automation.dao.js';
|
||||
import { isContained, isEquivalent, isGreaterThan, isLessThan } from './automation.utils.js';
|
||||
import { toOntimeAction } from './clients/ontime.client.js';
|
||||
|
||||
import { logger } from '../../classes/Logger.js';
|
||||
import { isOntimeCloud } from '../../setup/environment.js';
|
||||
import { eventStore } from '../../stores/EventStore.js';
|
||||
|
||||
/**
|
||||
* Exposes a method for triggering actions based on a TimerLifeCycle event
|
||||
*/
|
||||
export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) {
|
||||
export function triggerAutomations(cycle: TimerLifeCycle) {
|
||||
if (!getAutomationsEnabled()) {
|
||||
return;
|
||||
}
|
||||
const store = eventStore.poll();
|
||||
|
||||
let triggers = getAutomationTriggers();
|
||||
|
||||
// get triggers from event
|
||||
if (state.eventNow?.triggers) {
|
||||
triggers = triggers.concat(state.eventNow.triggers);
|
||||
if (store.eventNow?.triggers) {
|
||||
triggers = triggers.concat(store.eventNow.triggers);
|
||||
}
|
||||
|
||||
// note: there are no onStop triggers in event
|
||||
@@ -51,9 +53,9 @@ export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) {
|
||||
if (!automation || automation.outputs.length === 0) {
|
||||
return;
|
||||
}
|
||||
const shouldSend = testConditions(automation.filters, automation.filterRule, state);
|
||||
const shouldSend = testConditions(automation.filters, automation.filterRule, store);
|
||||
if (shouldSend) {
|
||||
send(automation.outputs, state);
|
||||
send(automation.outputs, store);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -62,7 +64,8 @@ export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) {
|
||||
* Exposes a method for bypassing the condition check and testing the sending of an output
|
||||
*/
|
||||
export function testOutput(payload: AutomationOutput) {
|
||||
send([payload]);
|
||||
const store = eventStore.poll();
|
||||
send([payload], store);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,7 +74,7 @@ export function testOutput(payload: AutomationOutput) {
|
||||
export function testConditions(
|
||||
filters: AutomationFilter[],
|
||||
filterRule: FilterRule,
|
||||
state: Partial<RuntimeState>,
|
||||
store: Partial<RuntimeStore>,
|
||||
): boolean {
|
||||
if (filters.length === 0) {
|
||||
return true;
|
||||
@@ -86,7 +89,7 @@ export function testConditions(
|
||||
function evaluateCondition(filter: AutomationFilter): boolean {
|
||||
const { field, operator, value } = filter;
|
||||
const lowerCasedValue = value.toLowerCase();
|
||||
const fieldValue = getPropertyFromPath(field, state);
|
||||
const fieldValue = getPropertyFromPath(field, store);
|
||||
|
||||
// if value is empty string, the user could be meaning to check if the value does not exist
|
||||
// we use loose equality to be able to check for converted values (eg '10' == 10)
|
||||
@@ -115,13 +118,12 @@ export function testConditions(
|
||||
* Handles preparing and sending of the data
|
||||
* Returns a boolean indicating whether a message was sent
|
||||
*/
|
||||
function send(output: AutomationOutput[], state?: RuntimeState) {
|
||||
const stateSnapshot = state ?? getState();
|
||||
function send(output: AutomationOutput[], store: RuntimeStore) {
|
||||
output.forEach((payload) => {
|
||||
if (isOSCOutput(payload) && !isOntimeCloud) {
|
||||
emitOSC(payload, stateSnapshot);
|
||||
emitOSC(payload, store);
|
||||
} else if (isHTTPOutput(payload)) {
|
||||
emitHTTP(payload, stateSnapshot);
|
||||
emitHTTP(payload, store);
|
||||
} else if (isOntimeAction(payload)) {
|
||||
toOntimeAction(payload);
|
||||
} else {
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { HTTPOutput, LogOrigin } from 'ontime-types';
|
||||
import { DeepReadonly } from 'ts-essentials';
|
||||
import { HTTPOutput, LogOrigin, RuntimeStore } from 'ontime-types';
|
||||
|
||||
import { logger } from '../../../classes/Logger.js';
|
||||
import type { RuntimeState } from '../../../stores/runtimeState.js';
|
||||
|
||||
import { parseTemplateNested } from '../automation.utils.js';
|
||||
|
||||
/**
|
||||
* Expose possibility to send a message using HTTP protocol
|
||||
*/
|
||||
export function emitHTTP(output: HTTPOutput, state: RuntimeState) {
|
||||
const url = preparePayload(output, state);
|
||||
export function emitHTTP(output: HTTPOutput, store: DeepReadonly<RuntimeStore>) {
|
||||
const url = preparePayload(output, store);
|
||||
emit(url);
|
||||
}
|
||||
|
||||
/** Parses the state and prepares payload to be emitted */
|
||||
function preparePayload(output: HTTPOutput, state: RuntimeState): string {
|
||||
function preparePayload(output: HTTPOutput, state: DeepReadonly<RuntimeStore>): string {
|
||||
const parsedUrl = parseTemplateNested(output.url, state);
|
||||
return parsedUrl;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { LogOrigin, OSCOutput } from 'ontime-types';
|
||||
import { LogOrigin, OSCOutput, RuntimeStore } from 'ontime-types';
|
||||
|
||||
import { type OscPacketInput, toBuffer as oscPacketToBuffer } from 'osc-min';
|
||||
import * as dgram from 'node:dgram';
|
||||
|
||||
import { logger } from '../../../classes/Logger.js';
|
||||
import { type RuntimeState } from '../../../stores/runtimeState.js';
|
||||
import { parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
|
||||
import { DeepReadonly } from 'ts-essentials';
|
||||
|
||||
const udpClient = dgram.createSocket('udp4');
|
||||
|
||||
/**
|
||||
* Expose possibility to send a message using OSC protocol
|
||||
*/
|
||||
export function emitOSC(output: OSCOutput, state: RuntimeState) {
|
||||
const message = preparePayload(output, state);
|
||||
export function emitOSC(output: OSCOutput, store: DeepReadonly<RuntimeStore>) {
|
||||
const message = preparePayload(output, store);
|
||||
emit(output.targetIP, output.targetPort, message);
|
||||
}
|
||||
|
||||
/** Parses the state and prepares payload to be emitted */
|
||||
function preparePayload(output: OSCOutput, state: RuntimeState): OscPacketInput {
|
||||
function preparePayload(output: OSCOutput, store: DeepReadonly<RuntimeStore>): OscPacketInput {
|
||||
// check for templates in the address
|
||||
const parsedAddress = parseTemplateNested(output.address, state);
|
||||
const parsedAddress = parseTemplateNested(output.address, store);
|
||||
|
||||
// check for templates in the arguments
|
||||
const parsedArguments = output.args ? parseTemplateNested(output.args, state) : undefined;
|
||||
const parsedArguments = output.args ? parseTemplateNested(output.args, store) : undefined;
|
||||
// check we have the correct type
|
||||
const oscArguments = stringToOSCArgs(parsedArguments);
|
||||
return { address: parsedAddress, args: oscArguments };
|
||||
|
||||
@@ -10,6 +10,9 @@ import { parseUrlPresets } from '../url-presets/urlPresets.parser.js';
|
||||
import { parseViewSettings } from '../view-settings/viewSettings.parser.js';
|
||||
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
|
||||
import * as v3 from './migration/db.migration.v3.js';
|
||||
import * as v4 from './migration/db.migration.v4.js';
|
||||
import { portManager } from '../../classes/port-manager/PortManager.js';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
type ParsingError = {
|
||||
context: string;
|
||||
@@ -21,21 +24,45 @@ type ParsingError = {
|
||||
* @param {object} jsonData - project file to be parsed
|
||||
* @returns {object} parsed object
|
||||
*/
|
||||
export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): {
|
||||
export function parseDatabaseModel(
|
||||
jsonData: Partial<DatabaseModel>,
|
||||
initialLoad = false,
|
||||
): {
|
||||
data: DatabaseModel;
|
||||
errors: ParsingError[];
|
||||
migrated: boolean;
|
||||
} {
|
||||
|
||||
let migrated = false;
|
||||
let migratedData = jsonData;
|
||||
const errors: ParsingError[] = [];
|
||||
|
||||
if (v3.shouldUseThisMigration(jsonData)) {
|
||||
try {
|
||||
migrated = true;
|
||||
logger.warning(LogOrigin.Server, 'The imported project is from v3, trying to migrate');
|
||||
migratedData = v3.migrateAllData(jsonData);
|
||||
} catch (_error) {
|
||||
} catch (error) {
|
||||
logger.error(LogOrigin.Server, 'Failed to migrate the data');
|
||||
errors.push({ context: 'v3 migration', message: getErrorMessage(error) });
|
||||
migratedData = jsonData;
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (v4.shouldMigrateServerPort(migratedData)) {
|
||||
try {
|
||||
migrated = true;
|
||||
logger.warning(LogOrigin.Server, 'Migrating serverPort from settings to AppState');
|
||||
const { db, serverPort } = v4.migrateServerPort(migratedData);
|
||||
if (initialLoad && serverPort) portManager.migratePortFromProjectFile(serverPort);
|
||||
migratedData = db;
|
||||
} catch (error) {
|
||||
logger.error(LogOrigin.Server, 'Failed to migrate serverPort');
|
||||
errors.push({ context: 'v4 migration', message: getErrorMessage(error) });
|
||||
migratedData = jsonData;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +70,6 @@ export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): {
|
||||
// this may throw
|
||||
const settings = parseSettings(migratedData);
|
||||
|
||||
const errors: ParsingError[] = [];
|
||||
const makeEmitError = (context: string) => (message: string) => {
|
||||
logger.error(LogOrigin.Server, `Error parsing ${context}: ${message}`);
|
||||
errors.push({ context, message });
|
||||
|
||||
@@ -16,12 +16,17 @@ import {
|
||||
Trigger,
|
||||
URLPreset,
|
||||
ViewSettings,
|
||||
Day,
|
||||
} 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 { event as eventModel } from '../../../models/eventsDefinition.js';
|
||||
import { ONTIME_VERSION } from '../../../ONTIME_VERSION.js';
|
||||
import { getPartialProject } from '../../../models/dataModel.js';
|
||||
|
||||
// the methodology of the migrations is to just change the necessary keys to match with v4
|
||||
@@ -64,12 +69,12 @@ type old_Settings = {
|
||||
* migrates a settings from v3 to v4
|
||||
* - update the version number
|
||||
*/
|
||||
export function migrateSettings(jsonData: object): Settings | undefined {
|
||||
export function migrateSettings(jsonData: object): (Settings & { serverPort: number }) | undefined {
|
||||
if (is.objectWithKeys(jsonData, ['settings']) && is.object(jsonData.settings)) {
|
||||
const { serverPort, editorKey, operatorKey, timeFormat, language } = structuredClone(
|
||||
jsonData.settings,
|
||||
) as old_Settings;
|
||||
return { version: ONTIME_VERSION, serverPort, editorKey, operatorKey, timeFormat, language };
|
||||
return { version: '4.0.0', serverPort, editorKey, operatorKey, timeFormat, language };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,7 +394,7 @@ export function migrateRundown(
|
||||
// !==== RUNTIME METADATA ====! //
|
||||
revision: -1,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
dayOffset: 0 as Day,
|
||||
gap: 0,
|
||||
});
|
||||
} else if (entry.type === 'block') {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { DatabaseModel, Settings } from 'ontime-types';
|
||||
import { is } from '../../../utils/is.js';
|
||||
|
||||
export function shouldMigrateServerPort(jsonData: object): boolean {
|
||||
return (
|
||||
is.objectWithKeys(jsonData, ['settings']) &&
|
||||
is.object(jsonData.settings) &&
|
||||
is.objectWithKeys(jsonData.settings, ['version', 'serverPort']) &&
|
||||
typeof jsonData.settings.version === 'string' &&
|
||||
jsonData.settings.version.split('.')[0] === '4' &&
|
||||
Number(jsonData.settings.version.split('.')[1]) <= 4
|
||||
);
|
||||
}
|
||||
|
||||
export function migrateServerPort(jsonData: Partial<DatabaseModel>): {
|
||||
db: Partial<DatabaseModel>;
|
||||
serverPort?: number;
|
||||
} {
|
||||
const db = structuredClone(jsonData);
|
||||
const settings = db.settings as Partial<Settings & { serverPort: number }>;
|
||||
const editorKey = settings?.editorKey;
|
||||
const operatorKey = settings?.operatorKey;
|
||||
const timeFormat = settings?.timeFormat;
|
||||
const language = settings?.language;
|
||||
const version = '4.5.0';
|
||||
db.settings = {
|
||||
version,
|
||||
editorKey,
|
||||
operatorKey,
|
||||
timeFormat,
|
||||
language,
|
||||
app: 'ontime',
|
||||
} as Settings;
|
||||
return { db, serverPort: settings?.serverPort };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
AutomationSettings,
|
||||
CustomFields,
|
||||
DatabaseModel,
|
||||
EndAction,
|
||||
OntimeView,
|
||||
ProjectData,
|
||||
@@ -14,8 +15,7 @@ import {
|
||||
ViewSettings,
|
||||
} from 'ontime-types';
|
||||
import * as v3 from './db.migration.v3.js';
|
||||
|
||||
import { ONTIME_VERSION } from '../../../ONTIME_VERSION.js';
|
||||
import * as v4 from './db.migration.v4.js';
|
||||
|
||||
describe('v3 to v4', () => {
|
||||
const oldDb = {
|
||||
@@ -175,8 +175,8 @@ describe('v3 to v4', () => {
|
||||
};
|
||||
|
||||
test('migrate settings', () => {
|
||||
const expectSettings: Settings = {
|
||||
version: ONTIME_VERSION,
|
||||
const expectSettings: Settings & { serverPort: number } = {
|
||||
version: '4.0.0',
|
||||
serverPort: 4001,
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
@@ -468,7 +468,7 @@ describe('v3 to v4', () => {
|
||||
|
||||
const expectedAutomation: AutomationSettings = {
|
||||
enabledAutomations: true,
|
||||
enabledOscIn: true,
|
||||
enabledOscIn: false,
|
||||
oscPortIn: 8888,
|
||||
triggers: [
|
||||
{
|
||||
@@ -493,3 +493,101 @@ describe('v3 to v4', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('v4 remove server port', () => {
|
||||
const demoDb = {
|
||||
rundowns: {},
|
||||
project: {
|
||||
title: 'Eurovision Song Contest',
|
||||
description: 'Turin 2022',
|
||||
url: 'www.github.com/cpvalente/ontime',
|
||||
info: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
|
||||
logo: null,
|
||||
custom: [],
|
||||
},
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: '4.0.0',
|
||||
serverPort: 4001,
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
},
|
||||
viewSettings: {
|
||||
dangerColor: '#ff7300',
|
||||
normalColor: '#ffffffcc',
|
||||
overrideStyles: false,
|
||||
warningColor: '#ffa528',
|
||||
},
|
||||
customFields: {
|
||||
song: {
|
||||
label: 'Song',
|
||||
type: 'text',
|
||||
colour: '#339E4E',
|
||||
},
|
||||
artist: {
|
||||
label: 'Artist',
|
||||
type: 'text',
|
||||
colour: '#3E75E8',
|
||||
},
|
||||
},
|
||||
urlPresets: [
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'clock',
|
||||
target: 'timer',
|
||||
search:
|
||||
'timer?showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'minimal',
|
||||
target: 'timer',
|
||||
search:
|
||||
'timer?showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
||||
},
|
||||
],
|
||||
automation: {
|
||||
enabledAutomations: false,
|
||||
enabledOscIn: true,
|
||||
oscPortIn: 8888,
|
||||
triggers: [],
|
||||
automations: {},
|
||||
},
|
||||
};
|
||||
|
||||
it('should migrate if server port exists', () => {
|
||||
expect(v4.shouldMigrateServerPort(demoDb)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should not migrate if the version is newer', () => {
|
||||
expect(
|
||||
v4.shouldMigrateServerPort({ settings: { version: '5.0.0', serverPort: 4001 } } as unknown as DatabaseModel),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should not migrate if there is no server port', () => {
|
||||
expect(v4.shouldMigrateServerPort({ settings: { version: '4.0.0' } } as DatabaseModel)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('remove server port from project', () => {
|
||||
const { db: result, serverPort } = v4.migrateServerPort(demoDb as DatabaseModel);
|
||||
expect(result).not.toHaveProperty('settings.serverPort');
|
||||
expect(serverPort).toBe(4001);
|
||||
expect(result.automation).toMatchObject(demoDb.automation);
|
||||
expect(result.customFields).toMatchObject(demoDb.customFields);
|
||||
expect(result.project).toMatchObject(demoDb.project);
|
||||
expect(result.rundowns).toMatchObject(demoDb.rundowns);
|
||||
expect(result.settings).toMatchObject({
|
||||
app: 'ontime',
|
||||
version: '4.5.0',
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
});
|
||||
expect(result.urlPresets).toMatchObject(demoDb.urlPresets);
|
||||
expect(result.viewSettings).toMatchObject(demoDb.viewSettings);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,12 +18,12 @@ import {
|
||||
isKnownTimerType,
|
||||
validateTimerType,
|
||||
validateEndAction,
|
||||
makeString,
|
||||
} from 'ontime-utils';
|
||||
|
||||
import { Prettify } from 'ts-essentials';
|
||||
|
||||
import { is } from '../../utils/is.js';
|
||||
import { makeString } from '../../utils/parserUtils.js';
|
||||
import { parseExcelDate } from '../../utils/time.js';
|
||||
import { generateImportHandlers, getCustomFieldData, parseBooleanString, SheetMetadata } from './excel.utils.js';
|
||||
|
||||
|
||||
@@ -33,10 +33,11 @@ export function getCustomFieldData(
|
||||
// we lower case the excel key to make it easier to match
|
||||
const columnNameInExcel = importMap.custom[ontimeLabel].toLowerCase();
|
||||
const maybeExistingColour = existingCustomFields[keyInCustomFields]?.colour ?? '';
|
||||
const maybeExistingType = existingCustomFields[keyInCustomFields]?.type ?? 'text';
|
||||
|
||||
// 1. add the custom field to the merged custom fields
|
||||
mergedCustomFields[keyInCustomFields] = {
|
||||
type: 'text', // we currently only support text custom fields
|
||||
type: maybeExistingType,
|
||||
colour: maybeExistingColour,
|
||||
label: ontimeLabel,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TimeStrategy, EndAction, TimerType, OntimeEvent, OntimeGroup } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
import { TimeStrategy, EndAction, TimerType, OntimeEvent } from 'ontime-types';
|
||||
import { createEvent, MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
import { assertType } from 'vitest';
|
||||
|
||||
@@ -7,11 +7,9 @@ import { demoDb } from '../../../models/demoProject.js';
|
||||
|
||||
import {
|
||||
calculateDayOffset,
|
||||
createEvent,
|
||||
deleteById,
|
||||
doesInvalidateMetadata,
|
||||
duplicateRundown,
|
||||
getInsertAfterId,
|
||||
hasChanges,
|
||||
makeDeepClone,
|
||||
} 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', () => {
|
||||
it('duplicates a given rundown', () => {
|
||||
const demoRundown = demoDb.rundowns['default'];
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
Rundown,
|
||||
InsertOptions,
|
||||
} 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 { consoleError } from '../../utils/console.js';
|
||||
@@ -35,10 +35,8 @@ import type { RundownMetadata } from './rundown.types.js';
|
||||
import {
|
||||
applyPatchToEntry,
|
||||
cloneSimpleRundownEntry,
|
||||
createGroup,
|
||||
deleteById,
|
||||
doesInvalidateMetadata,
|
||||
getInsertAfterId,
|
||||
getUniqueId,
|
||||
makeDeepClone,
|
||||
} from './rundown.utils.js';
|
||||
@@ -110,11 +108,6 @@ export function createTransaction(options: TransactionOptions): Transaction {
|
||||
function commit(shouldProcess: boolean = true) {
|
||||
// if the rundown is mutable we persist the changes
|
||||
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
|
||||
cachedRundown.revision = cachedRundown.revision + 1;
|
||||
cachedRundown.title = rundown.title;
|
||||
@@ -135,16 +128,17 @@ export function createTransaction(options: TransactionOptions): Transaction {
|
||||
cachedRundown.flatOrder = metadata.flatEntryOrder;
|
||||
rundownMetadata = metadata;
|
||||
}
|
||||
|
||||
// persist after all mutations are applied
|
||||
getDataProvider().setRundown(cachedRundown.id, cachedRundown);
|
||||
}
|
||||
|
||||
// if the customFields are mutable we persist the changes
|
||||
if (options.mutableCustomFields) {
|
||||
// schedule a database update
|
||||
setImmediate(async () => {
|
||||
await getDataProvider().setCustomFields(projectCustomFields);
|
||||
});
|
||||
|
||||
projectCustomFields = customFields;
|
||||
|
||||
// persist after reassignment
|
||||
getDataProvider().setCustomFields(projectCustomFields);
|
||||
}
|
||||
|
||||
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
|
||||
* @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;
|
||||
}
|
||||
|
||||
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 = {
|
||||
add,
|
||||
add: addToRundown,
|
||||
edit,
|
||||
remove,
|
||||
removeAll,
|
||||
@@ -598,10 +548,8 @@ export const rundownMutation = {
|
||||
/**
|
||||
* Exposes a way to update a rundown which is not active
|
||||
*/
|
||||
export function updateBackgroundRundown(rundownId: string, rundown: Rundown) {
|
||||
setImmediate(async () => {
|
||||
await getDataProvider().setRundown(rundownId, rundown);
|
||||
});
|
||||
export async function updateBackgroundRundown(rundownId: string, rundown: Rundown) {
|
||||
await getDataProvider().setRundown(rundownId, rundown);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -698,9 +646,7 @@ export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Rea
|
||||
rundownMetadata = metadata;
|
||||
|
||||
// defer writing to the database
|
||||
setImmediate(async () => {
|
||||
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
|
||||
});
|
||||
getDataProvider().setRundown(cachedRundown.id, cachedRundown);
|
||||
|
||||
return { rundown, rundownMetadata, customFields, revision: rundown.revision };
|
||||
}
|
||||
|
||||
@@ -16,14 +16,24 @@ import {
|
||||
isOntimeMilestone,
|
||||
OntimeMilestone,
|
||||
OntimeGroup,
|
||||
Day,
|
||||
} 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 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';
|
||||
|
||||
/**
|
||||
@@ -107,7 +117,7 @@ export function parseRundown(
|
||||
cleanupCustomFields(newEvent.custom, parsedCustomFields);
|
||||
eventIndex += 1;
|
||||
} else if (isOntimeDelay(event)) {
|
||||
newEvent = { ...delayDef, duration: event.duration, id };
|
||||
newEvent = createDelay({ duration: event.duration, id });
|
||||
} else if (isOntimeMilestone(event)) {
|
||||
newEvent = createMilestone({ ...event, id });
|
||||
cleanupCustomFields(newEvent.custom, parsedCustomFields);
|
||||
@@ -134,7 +144,7 @@ export function parseRundown(
|
||||
cleanupCustomFields(newNestedEvent.custom, parsedCustomFields);
|
||||
eventIndex += 1;
|
||||
} else if (isOntimeDelay(nestedEvent)) {
|
||||
newNestedEvent = { ...delayDef, duration: nestedEvent.duration, id: nestedEventId };
|
||||
newNestedEvent = createDelay({ duration: nestedEvent.duration, id: nestedEventId });
|
||||
newNestedEvent.parent = event.id;
|
||||
} else if (isOntimeMilestone(nestedEvent)) {
|
||||
newNestedEvent = createMilestone({ ...nestedEvent, id: nestedEventId });
|
||||
@@ -272,7 +282,7 @@ function processEntry<T extends OntimeEntry>(
|
||||
sanitiseCustomFields(customFields, currentEntry);
|
||||
|
||||
processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent);
|
||||
currentEntry.dayOffset = processedData.totalDays;
|
||||
currentEntry.dayOffset = processedData.totalDays as Day;
|
||||
currentEntry.delay = 0; // this means we dont calculate delays or gaps for skipped events
|
||||
currentEntry.gap = 0; // this means we dont calculate delays or gaps for skipped events
|
||||
currentEntry.parent = childOfGroup;
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
ProjectRundowns,
|
||||
InsertOptions,
|
||||
} from 'ontime-types';
|
||||
import { customFieldLabelToKey } from 'ontime-utils';
|
||||
import { customFieldLabelToKey, getInsertAfterId, resolveInsertParent } from 'ontime-utils';
|
||||
|
||||
import { updateRundownData } from '../../stores/runtimeState.js';
|
||||
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
updateBackgroundRundown,
|
||||
} from './rundown.dao.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';
|
||||
|
||||
/**
|
||||
@@ -47,28 +47,15 @@ export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry
|
||||
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;
|
||||
|
||||
if ('parent' in eventData && eventData.parent != null) {
|
||||
// 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 (parentId) {
|
||||
const maybeParent = rundown.entries[parentId];
|
||||
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;
|
||||
} 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
|
||||
@@ -499,7 +486,7 @@ export async function editCustomField(
|
||||
if (rundownId !== rundown.id) {
|
||||
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
|
||||
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) {
|
||||
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
|
||||
customFieldMutation.removeUsages(backgroundRundown, key);
|
||||
updateBackgroundRundown(rundownId, backgroundRundown);
|
||||
await updateBackgroundRundown(rundownId, backgroundRundown);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,19 +22,16 @@ import {
|
||||
dayInMs,
|
||||
generateId,
|
||||
getCueCandidate,
|
||||
createDelay,
|
||||
createEvent,
|
||||
createGroup,
|
||||
createMilestone,
|
||||
makeString,
|
||||
validateEndAction,
|
||||
validateTimerType,
|
||||
validateTimes,
|
||||
} 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';
|
||||
|
||||
type CompleteEntry<T> =
|
||||
@@ -61,7 +58,7 @@ export function generateEvent<
|
||||
const id = eventData.id || getUniqueId(rundown);
|
||||
|
||||
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
|
||||
@@ -198,74 +195,6 @@ export function applyPatchToEntry(eventFromRundown: OntimeEntry, patch: Partial<
|
||||
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
|
||||
* @param end
|
||||
@@ -483,31 +412,6 @@ export function calculateDayOffset(
|
||||
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
|
||||
* - if it does not exist in the project
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getTimezoneLabel } from '../../utils/time.js';
|
||||
import { password, routerPrefix } from '../../externals.js';
|
||||
import { hashPassword } from '../../utils/hash.js';
|
||||
import { ONTIME_VERSION } from '../../ONTIME_VERSION.js';
|
||||
import { portManager } from '../../classes/port-manager/PortManager.js';
|
||||
|
||||
const startedAt = new Date();
|
||||
|
||||
@@ -37,7 +38,8 @@ export async function getSessionStats(): Promise<SessionStats> {
|
||||
* Adds business logic to gathering data for the info endpoint
|
||||
*/
|
||||
export async function getInfo(): Promise<GetInfo> {
|
||||
const { version, serverPort } = getDataProvider().getSettings();
|
||||
const { version } = getDataProvider().getSettings();
|
||||
const { port } = portManager.getPort();
|
||||
|
||||
// get nif and inject localhost
|
||||
const ni = getNetworkInterfaces();
|
||||
@@ -46,7 +48,7 @@ export async function getInfo(): Promise<GetInfo> {
|
||||
return {
|
||||
networkInterfaces: ni,
|
||||
version,
|
||||
serverPort,
|
||||
serverPort: port,
|
||||
publicDir: publicDir.root,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ describe('parseSettings()', () => {
|
||||
expect(result).toBeTypeOf('object');
|
||||
expect(result).toMatchObject({
|
||||
version: expect.any(String),
|
||||
serverPort: 4001,
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
|
||||
@@ -18,7 +18,6 @@ export function parseSettings(data: Partial<DatabaseModel>): Settings {
|
||||
|
||||
return {
|
||||
version: defaultSettings.version,
|
||||
serverPort: data.settings.serverPort ?? defaultSettings.serverPort,
|
||||
editorKey: data.settings.editorKey ?? defaultSettings.editorKey,
|
||||
operatorKey: data.settings.operatorKey ?? defaultSettings.operatorKey,
|
||||
timeFormat: data.settings.timeFormat ?? defaultSettings.timeFormat,
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import express from 'express';
|
||||
import { matchedData } from 'express-validator';
|
||||
import type { Request, Response } from 'express';
|
||||
import { deepEqual } from 'fast-equals';
|
||||
import express from "express";
|
||||
import { matchedData } from "express-validator";
|
||||
import type { Request, Response } from "express";
|
||||
import { deepEqual } from "fast-equals";
|
||||
|
||||
import { ErrorResponse, RefetchKey, Settings } from 'ontime-types';
|
||||
import { getErrorMessage, obfuscate } from 'ontime-utils';
|
||||
import { ErrorResponse, PortInfo, RefetchKey, Settings } from "ontime-types";
|
||||
import { getErrorMessage, obfuscate } from "ontime-utils";
|
||||
|
||||
import { validateSettings, validateWelcomeDialog } from './settings.validation.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import * as appState from '../../services/app-state-service/AppStateService.js';
|
||||
import { isDocker } from '../../setup/environment.js';
|
||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||
import {
|
||||
validateSettings,
|
||||
validateWelcomeDialog,
|
||||
validateServerPort,
|
||||
} from "./settings.validation.js";
|
||||
import { getDataProvider } from "../../classes/data-provider/DataProvider.js";
|
||||
import * as appState from "../../services/app-state-service/AppStateService.js";
|
||||
import { sendRefetch } from "../../adapters/WebsocketAdapter.js";
|
||||
import { portManager } from "../../classes/port-manager/PortManager.js";
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
router.post('/welcomedialog', validateWelcomeDialog, async (req: Request, res: Response) => {
|
||||
router.post("/welcomedialog", validateWelcomeDialog, async (req: Request, res: Response) => {
|
||||
const show = await appState.setShowWelcomeDialog(req.body.show);
|
||||
res.status(200).json({ show });
|
||||
});
|
||||
|
||||
router.get('/', (_req: Request, res: Response<Settings>) => {
|
||||
router.get("/", (_req: Request, res: Response<Settings>) => {
|
||||
const settings = getDataProvider().getSettings();
|
||||
const obfuscatedSettings = { ...settings };
|
||||
if (settings.editorKey) {
|
||||
@@ -33,26 +37,52 @@ router.get('/', (_req: Request, res: Response<Settings>) => {
|
||||
res.status(200).json(obfuscatedSettings);
|
||||
});
|
||||
|
||||
router.post('/', validateSettings, async (req: Request, res: Response<Settings | ErrorResponse>) => {
|
||||
router.post(
|
||||
"/",
|
||||
validateSettings,
|
||||
async (req: Request, res: Response<Settings | ErrorResponse>) => {
|
||||
try {
|
||||
const data = matchedData<Settings>(req);
|
||||
const settings = getDataProvider().getSettings();
|
||||
|
||||
data.version = settings.version;
|
||||
|
||||
if (!deepEqual(data, settings)) {
|
||||
await getDataProvider().setSettings(data);
|
||||
sendRefetch(RefetchKey.Settings);
|
||||
}
|
||||
|
||||
res.status(200).json(data);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).json({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/serverport", (_req: Request, res: Response<PortInfo | ErrorResponse>) => {
|
||||
try {
|
||||
const data = matchedData<Settings>(req);
|
||||
const settings = getDataProvider().getSettings();
|
||||
|
||||
if (isDocker && settings.serverPort !== data.serverPort) {
|
||||
res.status(403).json({ message: 'Can`t change port when running inside docker' });
|
||||
return;
|
||||
}
|
||||
|
||||
data.version = settings.version;
|
||||
|
||||
if (!deepEqual(data, settings)) {
|
||||
await getDataProvider().setSettings(data);
|
||||
sendRefetch(RefetchKey.Settings);
|
||||
}
|
||||
|
||||
res.status(200).json(data);
|
||||
const { port, pendingRestart } = portManager.getPort();
|
||||
res.status(200).json({ port, pendingRestart });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).json({ message });
|
||||
res.status(500).json({ message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/serverport",
|
||||
validateServerPort,
|
||||
async (req: Request, res: Response<PortInfo | ErrorResponse>) => {
|
||||
try {
|
||||
const { serverPort } = matchedData<{ serverPort: number }>(req);
|
||||
portManager.changePort(serverPort);
|
||||
const { port, pendingRestart } = portManager.getPort();
|
||||
|
||||
res.status(200).json({ port, pendingRestart });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).json({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -26,7 +26,11 @@ export const validateSettings = [
|
||||
pinValidator('operatorKey'),
|
||||
body('timeFormat').isString().isIn(['12', '24']).withMessage('Time format can only be "12" or "24"'),
|
||||
body('language').isString().trim().notEmpty(),
|
||||
body('serverPort').isPort().withMessage('Invalid value found for server port').toInt(),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
export const validateServerPort = [
|
||||
body('serverPort').isPort().withMessage('Invalid value found for server port').toInt(),
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
@@ -43,18 +43,24 @@ router.post('/', validateNewPreset, async (req: Request, res: Response<URLPreset
|
||||
router.put('/:alias', validateUpdatePreset, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
|
||||
try {
|
||||
const alias = req.params.alias;
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
const existingPreset = currentPresets.find((preset) => preset.alias === alias);
|
||||
if (!existingPreset) {
|
||||
throw new Error(`Preset with alias ${alias} does not exist.`);
|
||||
}
|
||||
|
||||
const updatedPreset: URLPreset = {
|
||||
enabled: req.body.enabled,
|
||||
alias: req.body.alias,
|
||||
target: req.body.target,
|
||||
search: req.body.search,
|
||||
options: req.body.options ?? existingPreset.options,
|
||||
};
|
||||
|
||||
if (alias !== updatedPreset.alias) {
|
||||
throw new Error('Changing alias is not permitted');
|
||||
}
|
||||
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
const newPresets = currentPresets.map((preset) => (preset.alias === alias ? updatedPreset : preset));
|
||||
|
||||
// Update the URL presets in the data provider
|
||||
|
||||
+11
-9
@@ -25,7 +25,7 @@ import { integrationRouter } from './api-integration/integration.router.js';
|
||||
|
||||
// Import adapters
|
||||
import { socket } from './adapters/WebsocketAdapter.js';
|
||||
import { getDataProvider } from './classes/data-provider/DataProvider.js';
|
||||
import { getDataProvider, flushPendingWrites } from './classes/data-provider/DataProvider.js';
|
||||
|
||||
// Services
|
||||
import { logger } from './classes/Logger.js';
|
||||
@@ -46,7 +46,8 @@ import { oscServer } from './adapters/OscAdapter.js';
|
||||
import { clearUploadfolder } from './utils/upload.js';
|
||||
import { generateCrashReport } from './utils/generateCrashReport.js';
|
||||
import { timerConfig } from './setup/config.js';
|
||||
import { serverTryDesiredPort, getNetworkInterfaces } from './utils/network.js';
|
||||
import { getNetworkInterfaces } from './utils/network.js';
|
||||
import { portManager } from './classes/port-manager/PortManager.js';
|
||||
|
||||
console.log('\n');
|
||||
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||
@@ -176,16 +177,12 @@ export const initAssets = async (escalateErrorFn?: (error: string, unrecoverable
|
||||
*/
|
||||
export const startServer = async (): Promise<{ message: string; serverPort: number }> => {
|
||||
checkStart(OntimeStartOrder.InitServer);
|
||||
const settings = getDataProvider().getSettings();
|
||||
const { serverPort: desiredPort } = settings;
|
||||
|
||||
expressServer = http.createServer(app);
|
||||
|
||||
// the express server must be started before the socket otherwise the on error event listener will not attach properly
|
||||
const resultPort = await serverTryDesiredPort(expressServer, desiredPort);
|
||||
await getDataProvider().setSettings({ ...settings, serverPort: resultPort });
|
||||
const showWelcome = await getShowWelcomeDialog(!!restorePoint);
|
||||
expressServer = http.createServer(app);
|
||||
const resultPort = await portManager.attachServer(expressServer);
|
||||
|
||||
const showWelcome = await getShowWelcomeDialog(!!restorePoint);
|
||||
socket.init(expressServer, showWelcome, prefix);
|
||||
|
||||
/**
|
||||
@@ -266,6 +263,10 @@ export const startIntegrations = async () => {
|
||||
export const shutdown = async (exitCode = 0) => {
|
||||
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
|
||||
// 0 means it was a SIGNAL
|
||||
// 1 means crash -> keep the file
|
||||
@@ -274,6 +275,7 @@ export const shutdown = async (exitCode = 0) => {
|
||||
// 99 means there was a shutdown request from the UI
|
||||
if (exitCode === 0 || exitCode === 99) {
|
||||
await restoreService.clear();
|
||||
await portManager.shutdown();
|
||||
}
|
||||
|
||||
expressServer?.close();
|
||||
|
||||
@@ -180,10 +180,58 @@ async function mergeIntoData(newData: Partial<DatabaseModel>): ReadonlyPromise<D
|
||||
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() {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -72,14 +72,12 @@ describe('safeMerge', () => {
|
||||
it('merges the settings key', () => {
|
||||
const mergedData = safeMerge(baseDb, {
|
||||
settings: {
|
||||
serverPort: 3000,
|
||||
language: 'pt',
|
||||
version: 'new',
|
||||
} as Settings,
|
||||
});
|
||||
expect(mergedData.settings).toStrictEqual({
|
||||
version: 'new',
|
||||
serverPort: 3000,
|
||||
operatorKey: null,
|
||||
editorKey: null,
|
||||
timeFormat: baseDb.settings.timeFormat,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Server } from "http";
|
||||
import { config } from "../../setup/config.js";
|
||||
import { envPort, isDocker, isOntimeCloud } from "../../setup/environment.js";
|
||||
import * as appState from "../../services/app-state-service/AppStateService.js";
|
||||
import { logger } from "../Logger.js";
|
||||
import { LogOrigin, MaybeNumber } from "ontime-types";
|
||||
import { isAddressInfo, isPortInUseError } from "./PortManager.utils.js";
|
||||
import { shouldCrashDev } from "../../utils/development.js";
|
||||
|
||||
class PortManager {
|
||||
private static port: number;
|
||||
private static pendingRestart = false;
|
||||
private static newPort: MaybeNumber = null;
|
||||
|
||||
public getPort() {
|
||||
return {
|
||||
port: PortManager.port,
|
||||
pendingRestart: PortManager.pendingRestart,
|
||||
newPort: PortManager.newPort,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* marks that a port change is requested and will be applied on next restart
|
||||
* @throws if trying to change port inside docker
|
||||
* @param newPort
|
||||
* @returns {void}
|
||||
*/
|
||||
public changePort(newPort: number): void {
|
||||
if (isDocker) throw new Error("Can not change port when running inside docker");
|
||||
if (PortManager.port === newPort) return;
|
||||
PortManager.newPort = newPort;
|
||||
PortManager.pendingRestart = true;
|
||||
}
|
||||
|
||||
public migratePortFromProjectFile(port: number) {
|
||||
shouldCrashDev(
|
||||
PortManager.port !== undefined,
|
||||
"this function should not be called after `PortManager.port` has been initialized",
|
||||
);
|
||||
appState.setServerPort(port);
|
||||
}
|
||||
|
||||
public async shutdown() {
|
||||
if (PortManager.pendingRestart && PortManager.newPort != null) {
|
||||
logger.info(
|
||||
LogOrigin.Server,
|
||||
`A port change to ${PortManager.newPort} is pending and will take effect on next start`,
|
||||
);
|
||||
await appState.setServerPort(PortManager.newPort);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description tries to open the server with the desired port, and if getting a `EADDRINUSE` will change to a random port assigned by the OS
|
||||
* @param {http.Server} server http server object
|
||||
* @returns {Promise<number>} the resulting port number
|
||||
* @throws any other server errors will result in a throw
|
||||
*/
|
||||
public async attachServer(server: Server): Promise<number> {
|
||||
if (isOntimeCloud) {
|
||||
PortManager.port = await this.forceCloudPort(server);
|
||||
} else {
|
||||
PortManager.port =
|
||||
this.parsePort(envPort) || (await appState.getServerPort()) || config.defaultServerPort;
|
||||
PortManager.port = await this.tryServerPort(server);
|
||||
}
|
||||
await appState.setServerPort(PortManager.port);
|
||||
return PortManager.port;
|
||||
}
|
||||
|
||||
private parsePort(port: string | undefined) {
|
||||
if (typeof port !== "string") return null;
|
||||
if (port === "") return null;
|
||||
const maybePort = Number(port);
|
||||
if (isNaN(maybePort)) return null;
|
||||
return maybePort;
|
||||
}
|
||||
|
||||
private async tryServerPort(server: Server): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", (error) => {
|
||||
// we should only move ports if we are in a desktop environment
|
||||
if (isDocker) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPortInUseError(error)) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// if we get an address in use error, we will try to open the server in an ephemeral port
|
||||
// port 0 will assign an ephemeral port
|
||||
server.listen(0, "0.0.0.0", () => {
|
||||
const address = server.address();
|
||||
if (!isAddressInfo(address)) {
|
||||
reject(new Error("Unknown port type, unable to proceed"));
|
||||
return;
|
||||
}
|
||||
logger.error(
|
||||
LogOrigin.Server,
|
||||
`Failed to open the desired port: ${PortManager.port} \nMoved to an Ephemeral port: ${address.port}`,
|
||||
true,
|
||||
);
|
||||
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PortManager.port, "0.0.0.0", () => {
|
||||
const address = server.address();
|
||||
if (!isAddressInfo(address)) {
|
||||
reject(new Error("Unknown port type, unable to proceed"));
|
||||
return;
|
||||
}
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private forceCloudPort(server: Server): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", (error) => {
|
||||
reject(error);
|
||||
});
|
||||
server.listen(config.defaultServerPort, "0.0.0.0", () => {
|
||||
const address = server.address();
|
||||
if (!isAddressInfo(address)) {
|
||||
reject(new Error("Unknown port type, unable to proceed"));
|
||||
return;
|
||||
}
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const portManager = new PortManager();
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AddressInfo } from 'net';
|
||||
|
||||
/**
|
||||
* Checks whether a given error is a port in use error
|
||||
*/
|
||||
export function isPortInUseError(err: Error): boolean {
|
||||
return typeof err === 'object' && err !== null && 'code' in err && err.code === 'EADDRINUSE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard verifies that the given address is a usable AddressInfo object
|
||||
*/
|
||||
export function isAddressInfo(address: string | AddressInfo | null): address is AddressInfo {
|
||||
return typeof address === 'object' && address !== null;
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, millisToString } from 'ontime-utils';
|
||||
import { Duration, Instant, TimeOfDay } from 'ontime-types';
|
||||
|
||||
import { timeNow } from '../../../utils/time.js';
|
||||
import * as timeCore from '../timeCore.js';
|
||||
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// TZ is set to Europe/Copenhagen in vitest.global-setup.ts
|
||||
// Copenhagen is UTC+1 (CET) in winter and UTC+2 (CEST) in summer
|
||||
|
||||
describe('toTimeofDay() converts an instant to local milliseconds since midnight', () => {
|
||||
const testTimes = [
|
||||
{ time: '2025-01-15T08:30:00Z', label: 'winter morning' },
|
||||
{ time: '2025-06-15T14:00:00Z', label: 'summer afternoon' },
|
||||
{ time: '2025-01-01T00:00:00Z', label: 'midnight UTC on new years' },
|
||||
{ time: '2025-07-01T23:59:59Z', label: 'just before midnight UTC in summer' },
|
||||
{ time: '2025-03-15T12:00:00Z', label: 'noon UTC in winter' },
|
||||
{ time: '2025-09-15T12:00:00Z', label: 'noon UTC in summer' },
|
||||
];
|
||||
|
||||
test.each(testTimes)('produces the correct local time for $label ($time)', ({ time }) => {
|
||||
vi.setSystemTime(time);
|
||||
|
||||
const result = timeCore.toTimeOfDay(timeCore.now());
|
||||
expect(result).toBe(timeNow());
|
||||
});
|
||||
|
||||
it('returns a value in the range [0, dayInMs)', () => {
|
||||
vi.setSystemTime('2025-06-15T23:59:59.999Z');
|
||||
|
||||
const result = timeCore.toTimeOfDay(timeCore.now());
|
||||
expect(result).toBeGreaterThanOrEqual(0);
|
||||
expect(result).toBeLessThan(dayInMs);
|
||||
});
|
||||
|
||||
describe('handles DST transitions in Europe/Copenhagen', () => {
|
||||
it('produces CET time just before spring forward', () => {
|
||||
// 2025-03-30 at 02:00 CET clocks jump to 03:00 CEST
|
||||
// UTC 00:58:18 → Copenhagen CET (UTC+1) → local 01:58:18
|
||||
vi.setSystemTime('2025-03-30T00:58:18Z');
|
||||
|
||||
const result = timeCore.toTimeOfDay(timeCore.now());
|
||||
expect(millisToString(result)).toBe('01:58:18');
|
||||
});
|
||||
|
||||
it('produces CEST time just after spring forward', () => {
|
||||
// UTC 01:00:00 → Copenhagen CEST (UTC+2) → local 03:00:00
|
||||
// 02:00 local does not exist, clocks skip to 03:00
|
||||
vi.setSystemTime('2025-03-30T01:00:00Z');
|
||||
|
||||
const result = timeCore.toTimeOfDay(timeCore.now());
|
||||
expect(millisToString(result)).toBe('03:00:00');
|
||||
});
|
||||
|
||||
it('produces CEST time just before fall back', () => {
|
||||
// 2025-10-26 at 03:00 CEST clocks fall back to 02:00 CET
|
||||
// UTC 00:59:59 → Copenhagen CEST (UTC+2) → local 02:59:59
|
||||
vi.setSystemTime('2025-10-26T00:59:59Z');
|
||||
|
||||
const result = timeCore.toTimeOfDay(timeCore.now());
|
||||
expect(millisToString(result)).toBe('02:59:59');
|
||||
});
|
||||
|
||||
it('produces CET time just after fall back', () => {
|
||||
// UTC 01:00:00 → Copenhagen CET (UTC+1) → local 02:00:00
|
||||
vi.setSystemTime('2025-10-26T01:00:00Z');
|
||||
|
||||
const result = timeCore.toTimeOfDay(timeCore.now());
|
||||
expect(millisToString(result)).toBe('02:00:00');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toInstant() converts a time of day back to an instant anchored to a reference day', () => {
|
||||
const testTimes = [
|
||||
{ time: '2025-01-15T08:30:00Z', label: 'winter morning' },
|
||||
{ time: '2025-06-15T14:00:00Z', label: 'summer afternoon' },
|
||||
{ time: '2025-01-01T00:00:00Z', label: 'midnight UTC on new years' },
|
||||
{ time: '2025-07-01T23:59:59Z', label: 'just before midnight UTC in summer' },
|
||||
{ time: '2025-03-15T12:00:00Z', label: 'noon UTC in winter' },
|
||||
{ time: '2025-09-15T12:00:00Z', label: 'noon UTC in summer' },
|
||||
];
|
||||
|
||||
test.each(testTimes)('roundtrips through toTimeofDay for $label ($time)', ({ time }) => {
|
||||
vi.setSystemTime(time);
|
||||
|
||||
const instant = timeCore.now();
|
||||
const clock = timeCore.toTimeOfDay(instant);
|
||||
expect(timeCore.toInstant(clock, instant)).toBe(instant);
|
||||
});
|
||||
|
||||
describe('roundtrips through DST transitions in Europe/Copenhagen', () => {
|
||||
it('roundtrips just before spring forward', () => {
|
||||
vi.setSystemTime('2025-03-30T00:58:18Z');
|
||||
|
||||
const instant = timeCore.now();
|
||||
const clock = timeCore.toTimeOfDay(instant);
|
||||
expect(timeCore.toInstant(clock, instant)).toBe(instant);
|
||||
});
|
||||
|
||||
it('roundtrips just after spring forward', () => {
|
||||
vi.setSystemTime('2025-03-30T01:00:00Z');
|
||||
|
||||
const instant = timeCore.now();
|
||||
const clock = timeCore.toTimeOfDay(instant);
|
||||
expect(timeCore.toInstant(clock, instant)).toBe(instant);
|
||||
});
|
||||
|
||||
it('roundtrips just before fall back', () => {
|
||||
vi.setSystemTime('2025-10-26T00:59:59Z');
|
||||
|
||||
const instant = timeCore.now();
|
||||
const clock = timeCore.toTimeOfDay(instant);
|
||||
expect(timeCore.toInstant(clock, instant)).toBe(instant);
|
||||
});
|
||||
|
||||
it('roundtrips just after fall back', () => {
|
||||
vi.setSystemTime('2025-10-26T01:00:00Z');
|
||||
|
||||
const instant = timeCore.now();
|
||||
const clock = timeCore.toTimeOfDay(instant);
|
||||
expect(timeCore.toInstant(clock, instant)).toBe(instant);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeSince() returns the duration elapsed since a past point', () => {
|
||||
it('measures elapsed time between two instants', () => {
|
||||
const start = 1000 as Instant;
|
||||
const end = 5000 as Instant;
|
||||
expect(timeCore.timeSince(end, start)).toBe(4000);
|
||||
});
|
||||
|
||||
it('returns negative when the reference is in the future', () => {
|
||||
const start = 5000 as Instant;
|
||||
const end = 1000 as Instant;
|
||||
expect(timeCore.timeSince(end, start)).toBe(-4000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeUntil() returns the duration until a future point', () => {
|
||||
it('measures time remaining until a future instant', () => {
|
||||
const current = 1000 as Instant;
|
||||
const target = 5000 as Instant;
|
||||
expect(timeCore.timeUntil(current, target)).toBe(4000);
|
||||
});
|
||||
|
||||
it('returns negative when the target is in the past', () => {
|
||||
const current = 5000 as Instant;
|
||||
const target = 1000 as Instant;
|
||||
expect(timeCore.timeUntil(current, target)).toBe(-4000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addDuration() moves a point in time by a duration', () => {
|
||||
it('moves an instant forward', () => {
|
||||
const instant = 1000 as Instant;
|
||||
const duration = 500 as Duration;
|
||||
expect(timeCore.addDuration(instant, duration)).toBe(1500);
|
||||
});
|
||||
|
||||
it('moves backward with a negative duration', () => {
|
||||
const instant = 1000 as Instant;
|
||||
const duration = -300 as Duration;
|
||||
expect(timeCore.addDuration(instant, duration)).toBe(700);
|
||||
});
|
||||
|
||||
it('moves by the sum of multiple durations', () => {
|
||||
const instant = 1000 as Instant;
|
||||
const durations = [500, -300, 50] as Duration[];
|
||||
expect(timeCore.addDuration(instant, durations)).toBe(1250);
|
||||
});
|
||||
|
||||
it('keeps the instant unchanged with an empty duration list', () => {
|
||||
const instant = 1000 as Instant;
|
||||
expect(timeCore.addDuration(instant, [])).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('elapsedTime() calculates duration between two times of day', () => {
|
||||
it('calculates elapsed time on the same day', () => {
|
||||
const start = (10 * MILLIS_PER_HOUR) as TimeOfDay; // 10:00
|
||||
const clock = (10 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE) as TimeOfDay; // 10:30
|
||||
expect(timeCore.elapsedTime(clock, start)).toBe(30 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
it('calculates elapsed time when crossing midnight (overnight)', () => {
|
||||
const start = (23 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE) as TimeOfDay; // 23:50
|
||||
const clock = (21 * MILLIS_PER_MINUTE) as TimeOfDay; // 00:21
|
||||
// From 23:50 to 00:21 = 31 minutes
|
||||
expect(timeCore.elapsedTime(clock, start)).toBe(31 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
it('returns 0 when start and clock are the same', () => {
|
||||
const time = (15 * MILLIS_PER_HOUR) as TimeOfDay; // 15:00
|
||||
expect(timeCore.elapsedTime(time, time)).toBe(0);
|
||||
});
|
||||
|
||||
it('calculates correctly for just after midnight', () => {
|
||||
const start = (23 * MILLIS_PER_HOUR + 59 * MILLIS_PER_MINUTE) as TimeOfDay; // 23:59
|
||||
const clock = (1 * MILLIS_PER_MINUTE) as TimeOfDay; // 00:01
|
||||
// From 23:59 to 00:01 = 2 minutes
|
||||
expect(timeCore.elapsedTime(clock, start)).toBe(2 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('daysSinceStart() calculates full days elapsed since a start epoch', () => {
|
||||
it('returns 0 when current epoch equals start epoch', () => {
|
||||
vi.setSystemTime('2025-01-15T10:00:00Z');
|
||||
const epoch = timeCore.now();
|
||||
expect(timeCore.daysSinceStart(epoch, epoch)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when less than one day has elapsed', () => {
|
||||
vi.setSystemTime('2025-01-15T10:00:00Z');
|
||||
const startEpoch = timeCore.now();
|
||||
|
||||
vi.setSystemTime('2025-01-15T18:00:00Z'); // 8 hours later
|
||||
const currentEpoch = timeCore.now();
|
||||
|
||||
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 1 when crossing midnight once', () => {
|
||||
// Copenhagen is UTC+1 in winter, so 22:50 UTC = 23:50 local
|
||||
vi.setSystemTime('2025-01-15T22:50:00Z'); // 23:50 local
|
||||
const startEpoch = timeCore.now();
|
||||
|
||||
vi.setSystemTime('2025-01-15T23:21:00Z'); // 00:21 local next day
|
||||
const currentEpoch = timeCore.now();
|
||||
|
||||
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 2 when crossing midnight twice', () => {
|
||||
vi.setSystemTime('2025-01-15T10:00:00Z');
|
||||
const startEpoch = timeCore.now();
|
||||
|
||||
vi.setSystemTime('2025-01-17T15:00:00Z'); // 2 days + 5 hours later
|
||||
const currentEpoch = timeCore.now();
|
||||
|
||||
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(2);
|
||||
});
|
||||
|
||||
it('handles overnight start correctly', () => {
|
||||
// Copenhagen is UTC+1 in winter
|
||||
// Start at 23:50 local (22:50 UTC), check at 00:10 local next day (23:10 UTC)
|
||||
vi.setSystemTime('2025-01-15T22:50:00Z'); // 23:50 local
|
||||
const startEpoch = timeCore.now();
|
||||
|
||||
vi.setSystemTime('2025-01-15T23:10:00Z'); // 00:10 local next day
|
||||
const currentEpoch = timeCore.now();
|
||||
|
||||
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Day, Duration, Instant, TimeOfDay } from 'ontime-types';
|
||||
import { dayInMs, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
|
||||
/** Returns the current instant */
|
||||
export function now(): Instant {
|
||||
return Date.now() as Instant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an instant to milliseconds since midnight in the local timezone
|
||||
* - Accounts for the system's timezone offset including DST
|
||||
* - Result is always in the range [0, dayInMs)
|
||||
*/
|
||||
export function toTimeOfDay(instant: Instant): TimeOfDay {
|
||||
const tzOffset = new Date(instant).getTimezoneOffset() * MILLIS_PER_MINUTE;
|
||||
return ((((instant - tzOffset) % dayInMs) + dayInMs) % dayInMs) as TimeOfDay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current time of day in milliseconds since midnight
|
||||
*/
|
||||
export function timeOfDayNow(): TimeOfDay {
|
||||
return toTimeOfDay(now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a time of day to an instant anchored to the same day as the reference
|
||||
* - Uses the reference instant to determine which calendar day to anchor to
|
||||
*/
|
||||
export function toInstant(clock: TimeOfDay, reference: Instant): Instant {
|
||||
const referenceClock = toTimeOfDay(reference);
|
||||
const dayStart = reference - referenceClock;
|
||||
return (dayStart + clock) as Instant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the duration elapsed since a past instant
|
||||
* Result is positive when 'since' is before 'now'
|
||||
*/
|
||||
export function timeSince(now: Instant, since: Instant): Duration {
|
||||
return (now - since) as Duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the duration remaining until a future instant
|
||||
* Result is positive when 'until' is after 'now'
|
||||
*/
|
||||
export function timeUntil(now: Instant, until: Instant): Duration {
|
||||
return (until - now) as Duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves an instant forward or backward by a duration
|
||||
* Use a negative duration to move backward
|
||||
*/
|
||||
export function addDuration(instant: Instant, duration: Duration | Duration[]): Instant {
|
||||
const totalDuration = Array.isArray(duration) ? duration.reduce((total, current) => total + current, 0) : duration;
|
||||
|
||||
return (instant + totalDuration) as Instant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates elapsed time on the clock from a starting time to the current time
|
||||
* Handles overnight crossing (when current < start, assumes we've crossed midnight)
|
||||
*/
|
||||
export function elapsedTime(current: TimeOfDay, start: TimeOfDay): Duration {
|
||||
return (current < start ? current + dayInMs - start : current - start) as Duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the number of full days elapsed since a start epoch
|
||||
* Uses the start time-of-day to determine day boundaries
|
||||
*/
|
||||
export function daysSinceStart(startEpoch: Instant, currentEpoch: Instant): Day {
|
||||
const startClock = toTimeOfDay(startEpoch);
|
||||
const elapsedMs = currentEpoch - startEpoch;
|
||||
return Math.floor((elapsedMs + startClock) / dayInMs) as Day;
|
||||
}
|
||||
@@ -25,7 +25,6 @@ const dbModel: DatabaseModel = {
|
||||
},
|
||||
settings: {
|
||||
version: ONTIME_VERSION,
|
||||
serverPort: 4001,
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
@@ -41,7 +40,7 @@ const dbModel: DatabaseModel = {
|
||||
customFields: {},
|
||||
automation: {
|
||||
enabledAutomations: true,
|
||||
enabledOscIn: true,
|
||||
enabledOscIn: false,
|
||||
oscPortIn: 8888,
|
||||
triggers: [],
|
||||
automations: {},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user