mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-04 15:08:01 +00:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5e9a2549e | |||
| f4692db021 | |||
| dddb11ff40 | |||
| 6178ed1e4e | |||
| 8c5aaa0901 | |||
| b06ab15190 | |||
| a48be0f017 | |||
| c2280da61f | |||
| fe30752130 | |||
| a20d63451b | |||
| 93622e9aeb | |||
| 1568af97fc | |||
| 4bb836b6f2 | |||
| 8156da03d5 | |||
| dfade25a7c | |||
| 4b21745fc7 | |||
| a91a8a6358 | |||
| 4e6b833e10 | |||
| 3fdb1bd1c8 | |||
| 4148a3835b | |||
| 4fb9c42aef | |||
| dd0dcfc2a2 | |||
| d41d1b054d | |||
| 8b6e06150a | |||
| a9e0e2b091 | |||
| 0a8155b6e3 | |||
| 118c29e5c2 | |||
| 105f395e6e |
@@ -45,3 +45,6 @@ apps/server/src/preloaded-db/db.json
|
||||
|
||||
# versioning file
|
||||
**/ONTIME_VERSION.js
|
||||
|
||||
# temporary write files
|
||||
**.tmp
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:16-alpine
|
||||
FROM node:18.18-alpine
|
||||
|
||||
# Set environment variables
|
||||
# Environment Variable to signal that we are running production
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@mantine/hooks": "^7.6.2",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@sentry/react": "^7.92.0",
|
||||
"@tanstack/react-query": "^5.17.9",
|
||||
|
||||
@@ -47,9 +47,8 @@ function App() {
|
||||
}
|
||||
};
|
||||
}, [isElectron, sendToElectron]);
|
||||
|
||||
return (
|
||||
<ChakraProvider resetCSS theme={theme}>
|
||||
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
|
||||
<QueryClientProvider client={ontimeQueryClient}>
|
||||
<AppContextProvider>
|
||||
<BrowserRouter>
|
||||
|
||||
@@ -1,36 +1,60 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import {
|
||||
CustomFields,
|
||||
DatabaseModel,
|
||||
GetInfo,
|
||||
MessageResponse,
|
||||
OntimeRundown,
|
||||
ProjectData,
|
||||
ProjectFileListResponse,
|
||||
} from 'ontime-types';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import fileDownload from './utils';
|
||||
import { createBlob, downloadBlob } from './utils';
|
||||
|
||||
const dbPath = `${apiEntryUrl}/db`;
|
||||
|
||||
/**
|
||||
* HTTP request to download db in JSON format
|
||||
* HTTP request to the current DB
|
||||
*/
|
||||
export async function downloadRundown(fileName?: string) {
|
||||
return fileDownload(
|
||||
dbPath,
|
||||
{ name: fileName ?? 'rundown', type: 'json' },
|
||||
{ type: 'application/json;charset=utf-8;' },
|
||||
);
|
||||
async function getDb(): Promise<AxiosResponse<DatabaseModel>> {
|
||||
return axios.get(`${dbPath}/download`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to download db in CSV format
|
||||
* Request download of the current project file
|
||||
* @param fileName
|
||||
*/
|
||||
export async function downloadCSV(fileName?: string) {
|
||||
return fileDownload(dbPath, { name: fileName ?? 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
|
||||
export async function downloadProject(fileName: string = 'ontime-project') {
|
||||
try {
|
||||
const { data, name } = await fileDownload(fileName);
|
||||
|
||||
const fileContent = JSON.stringify(data, null, 2);
|
||||
|
||||
const blob = createBlob(fileContent, 'application/json;charset=utf-8;');
|
||||
downloadBlob(blob, `${name}.json`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request download of the current rundown as a CSV file
|
||||
* @param fileName
|
||||
*/
|
||||
export async function downloadCSV(fileName: string = 'rundown') {
|
||||
try {
|
||||
const { data, name } = await fileDownload(fileName);
|
||||
const { project, rundown, customFields } = data;
|
||||
|
||||
const sheetData = makeTable(project, rundown, customFields);
|
||||
const fileContent = makeCSV(sheetData);
|
||||
|
||||
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
|
||||
downloadBlob(blob, `${name}.csv`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,28 +152,23 @@ export async function getInfo(): Promise<GetInfo> {
|
||||
return res.data;
|
||||
}
|
||||
|
||||
type PreviewSpreadsheetResponse = {
|
||||
rundown: OntimeRundown;
|
||||
customFields: CustomFields;
|
||||
};
|
||||
|
||||
/**
|
||||
* Make patch changes to the objects in the db
|
||||
* Utility function gets project from db
|
||||
* @param fileName
|
||||
* @returns
|
||||
*/
|
||||
export async function importSpreadsheetPreview(file: File, options: ImportMap): Promise<PreviewSpreadsheetResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('spreadsheet', file);
|
||||
formData.append('options', JSON.stringify(options));
|
||||
async function fileDownload(fileName: string): Promise<{ data: DatabaseModel; name: string }> {
|
||||
const response = await getDb();
|
||||
|
||||
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(
|
||||
`${dbPath}/spreadsheet/preview`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
},
|
||||
);
|
||||
const headerLine = response.headers['Content-Disposition'];
|
||||
|
||||
return response.data;
|
||||
// try and get the filename from the response
|
||||
let name = fileName;
|
||||
if (headerLine != null) {
|
||||
const startFileNameIndex = headerLine.indexOf('"') + 1;
|
||||
const endFileNameIndex = headerLine.lastIndexOf('"');
|
||||
name = headerLine.substring(startFileNameIndex, endFileNameIndex);
|
||||
}
|
||||
|
||||
return { data: response.data, name };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { CustomFields, OntimeRundown } from 'ontime-types';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const excelPath = `${apiEntryUrl}/excel`;
|
||||
|
||||
type PreviewSpreadsheetResponse = {
|
||||
rundown: OntimeRundown;
|
||||
customFields: CustomFields;
|
||||
};
|
||||
|
||||
/**
|
||||
* upload Excel file to server
|
||||
* @return string - file ID op the uploaded file
|
||||
*/
|
||||
export async function upload(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('excel', file);
|
||||
await axios.post(`${excelPath}/upload`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Worksheet names
|
||||
* @return string[] - array of available worksheets
|
||||
*/
|
||||
export async function getWorksheetNames(): Promise<string[]> {
|
||||
const response: AxiosResponse<string[]> = await axios.get(`${excelPath}/worksheets`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
|
||||
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
|
||||
options,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
@@ -9,7 +9,10 @@ const sheetsPath = `${apiEntryUrl}/sheets`;
|
||||
/**
|
||||
* HTTP request to verify whether we are authenticated with Google Sheet service
|
||||
*/
|
||||
export const verifyAuthenticationStatus = async (): Promise<{ authenticated: AuthenticationStatus }> => {
|
||||
export const verifyAuthenticationStatus = async (): Promise<{
|
||||
authenticated: AuthenticationStatus;
|
||||
sheetId: string;
|
||||
}> => {
|
||||
const response = await axios.get(`${sheetsPath}/connect`);
|
||||
return response.data;
|
||||
};
|
||||
@@ -58,6 +61,11 @@ export const previewRundown = async (
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getWorksheetNames = async (sheetId: string): Promise<string[]> => {
|
||||
const response: AxiosResponse<string[]> = await axios.post(`${sheetsPath}/${sheetId}/worksheets`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP request to upload the rundown to a google sheet
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,6 @@ import axios, { AxiosError } from 'axios';
|
||||
import { LogLevel } from 'ontime-types';
|
||||
import { generateId, millisToString } from 'ontime-utils';
|
||||
|
||||
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
import { addLog } from '../stores/logger';
|
||||
import { nowInMillis } from '../utils/time';
|
||||
@@ -56,63 +55,29 @@ export async function invalidateAllCaches() {
|
||||
await ontimeQueryClient.invalidateQueries();
|
||||
}
|
||||
|
||||
type FileOptions = {
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type BlobOptions = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets DB from backend and prepares a file to be downloaded
|
||||
* @param url
|
||||
* @param fileOptions
|
||||
* @param blobOptions
|
||||
* Creates blob from content
|
||||
* @param fileContent
|
||||
* @param type
|
||||
* @returns
|
||||
*/
|
||||
export default async function fileDownload(url: string, fileOptions: FileOptions, blobOptions: BlobOptions) {
|
||||
const response = await axios({
|
||||
url: `${url}/db`,
|
||||
method: 'GET',
|
||||
});
|
||||
export function createBlob(fileContent: string, type: string): Blob {
|
||||
return new Blob([fileContent], { type });
|
||||
}
|
||||
|
||||
const headerLine = response.headers['Content-Disposition'];
|
||||
let { name: fileName } = fileOptions;
|
||||
const { type: fileType } = fileOptions;
|
||||
const { project, rundown, customFields } = response.data;
|
||||
|
||||
// try and get the filename from the response
|
||||
if (headerLine != null) {
|
||||
const startFileNameIndex = headerLine.indexOf('"') + 1;
|
||||
const endFileNameIndex = headerLine.lastIndexOf('"');
|
||||
fileName = headerLine.substring(startFileNameIndex, endFileNameIndex);
|
||||
}
|
||||
|
||||
let fileContent = '';
|
||||
|
||||
if (fileType === 'json') {
|
||||
fileContent = JSON.stringify(response.data);
|
||||
fileName += '.json';
|
||||
}
|
||||
|
||||
if (fileType === 'csv') {
|
||||
const sheetData = makeTable(project, rundown, customFields);
|
||||
fileContent = makeCSV(sheetData);
|
||||
fileName += '.csv';
|
||||
}
|
||||
|
||||
const blob = new Blob([fileContent], { type: blobOptions.type });
|
||||
/**
|
||||
* downloads a blob
|
||||
* @param downloadUrl
|
||||
* @param fileName
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, fileName: string) {
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', downloadUrl);
|
||||
link.setAttribute('download', fileName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Clean up the URL.createObjectURL to release resources
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { useDisclosure } from '@chakra-ui/react';
|
||||
import { useFullscreen } from '@mantine/hooks';
|
||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
import { IoContract } from '@react-icons/all-files/io5/IoContract';
|
||||
@@ -11,7 +12,6 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import useClickOutside from '../../hooks/useClickOutside';
|
||||
import useFullscreen from '../../hooks/useFullscreen';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
import { isKeyEnter } from '../../utils/keyEvent';
|
||||
|
||||
@@ -22,7 +22,7 @@ import style from './NavigationMenu.module.scss';
|
||||
function NavigationMenu() {
|
||||
const location = useLocation();
|
||||
|
||||
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
||||
const { fullscreen, toggle } = useFullscreen();
|
||||
const { toggleMirror } = useViewOptionsStore();
|
||||
const [showButton, setShowButton] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -54,7 +54,7 @@ function NavigationMenu() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFullscreen = () => toggleFullScreen();
|
||||
const handleFullscreen = () => toggle();
|
||||
const handleMirror = () => toggleMirror();
|
||||
|
||||
const showEditFormDrawer = () => {
|
||||
@@ -85,7 +85,7 @@ function NavigationMenu() {
|
||||
}}
|
||||
>
|
||||
Toggle Fullscreen
|
||||
{isFullScreen ? <IoContract /> : <IoExpand />}
|
||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||
</div>
|
||||
<div
|
||||
className={style.link}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormEvent, useEffect } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
@@ -12,30 +12,26 @@ import {
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
||||
|
||||
import ParamInput from './ParamInput';
|
||||
import { ParamField } from './types';
|
||||
|
||||
import style from './ViewParamsEditor.module.scss';
|
||||
|
||||
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
||||
type SavedViewParams = Record<string, ViewParamsObj>;
|
||||
|
||||
/**
|
||||
* Makes a new URLSearchParams object from the given params object
|
||||
*/
|
||||
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
|
||||
const defaultValues = paramFields.reduce<Record<string, string>>((acc, { id, defaultValue }) => {
|
||||
return { ...acc, [id]: String(defaultValue) };
|
||||
acc[id] = String(defaultValue);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
|
||||
if (typeof value === 'string' && value.length) {
|
||||
if (defaultValues[id] === value) {
|
||||
return newSearchParams;
|
||||
}
|
||||
if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
|
||||
newSearchParams.set(id, value);
|
||||
|
||||
return newSearchParams;
|
||||
}
|
||||
|
||||
return newSearchParams;
|
||||
}, new URLSearchParams());
|
||||
};
|
||||
@@ -47,8 +43,6 @@ interface EditFormDrawerProps {
|
||||
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||
const { pathname } = useLocation();
|
||||
const [storedViewParams, setStoredViewParams] = useLocalStorage<SavedViewParams>('ontime-views', {});
|
||||
|
||||
useEffect(() => {
|
||||
const isEditing = searchParams.get('edit');
|
||||
@@ -58,27 +52,6 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
}
|
||||
}, [searchParams, onOpen]);
|
||||
|
||||
/**
|
||||
* disabling this for now, this feature needs more testing
|
||||
* - we seem to have a bug where this is conflicting with the aliases
|
||||
* - I wonder if the logic below needs to be inside an effect,
|
||||
* both localStorage and searchParams should trigger a component update when they change
|
||||
|
||||
useEffect(() => {
|
||||
const viewParamsObjFromLocalStorage = storedViewParams[pathname];
|
||||
|
||||
if (viewParamsObjFromLocalStorage !== undefined) {
|
||||
const defaultSearchParams = getURLSearchParamsFromObj(viewParamsObjFromLocalStorage);
|
||||
setSearchParams(defaultSearchParams);
|
||||
}
|
||||
|
||||
// linter is asking for `setSearchParams` & `storedViewParams` in the useEffect deps
|
||||
// rule is disabled since adding `setSearchParams` & `storedViewParams` results in unnecessary re-renders
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname]);
|
||||
|
||||
*/
|
||||
|
||||
const onCloseWithoutSaving = () => {
|
||||
onClose();
|
||||
|
||||
@@ -87,7 +60,6 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
};
|
||||
|
||||
const resetParams = () => {
|
||||
setStoredViewParams({ ...storedViewParams, [pathname]: {} });
|
||||
setSearchParams();
|
||||
};
|
||||
|
||||
@@ -96,8 +68,6 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
||||
|
||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
|
||||
|
||||
setStoredViewParams({ ...storedViewParams, [pathname]: newParamsObject });
|
||||
setSearchParams(newSearchParams);
|
||||
};
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'pending') return;
|
||||
if (!data) return;
|
||||
const previousEditor = sessionStorage.getItem(storageKeys.editor);
|
||||
|
||||
if (previousEditor && previousEditor === data.editorKey) {
|
||||
@@ -53,10 +52,6 @@ export const AppContextProvider = ({ children }: PropsWithChildren) => {
|
||||
return savedPin == null || savedPin === '' || pin === savedPin;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (permission === 'editor') {
|
||||
const correct = isValid(pin, data.editorKey);
|
||||
if (correct) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { unobfuscate } from 'ontime-utils';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { APP_SETTINGS } from '../api/constants';
|
||||
@@ -14,7 +15,17 @@ export default function useSettings() {
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
select: (data) => {
|
||||
const unobfuscated = { ...data };
|
||||
if (data.editorKey) {
|
||||
unobfuscated.editorKey = unobfuscate(data.editorKey);
|
||||
}
|
||||
if (data.operatorKey) {
|
||||
unobfuscated.operatorKey = unobfuscate(data.operatorKey);
|
||||
}
|
||||
return unobfuscated;
|
||||
},
|
||||
});
|
||||
|
||||
return { data, status, isFetching, isError, refetch };
|
||||
return { data: data ?? ontimePlaceholderSettings, status, isFetching, isError, refetch };
|
||||
}
|
||||
|
||||
@@ -25,9 +25,7 @@ import { forgivingStringToMillis } from '../utils/dateConfig';
|
||||
*/
|
||||
export const useEventAction = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const eventSettings = useEditorSettings((state) => state.eventSettings);
|
||||
const defaultPublic = eventSettings.defaultPublic;
|
||||
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
|
||||
const { defaultPublic, linkPrevious, defaultDuration } = useEditorSettings((state) => state.eventSettings);
|
||||
|
||||
/**
|
||||
* Calls mutation to add new event
|
||||
@@ -45,11 +43,12 @@ export const useEventAction = () => {
|
||||
after?: string;
|
||||
};
|
||||
|
||||
type EventOptions = BaseOptions & {
|
||||
defaultPublic?: boolean;
|
||||
lastEventId?: string;
|
||||
startTimeIsLastEnd?: boolean;
|
||||
};
|
||||
type EventOptions = BaseOptions &
|
||||
Partial<{
|
||||
defaultPublic: boolean;
|
||||
linkPrevious: boolean;
|
||||
lastEventId: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Adds an event to rundown
|
||||
@@ -61,27 +60,31 @@ export const useEventAction = () => {
|
||||
// ************* CHECK OPTIONS specific to events
|
||||
if (isOntimeEvent(newEvent)) {
|
||||
const applicationOptions = {
|
||||
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
||||
startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd,
|
||||
lastEventId: options?.lastEventId,
|
||||
after: options?.after,
|
||||
defaultPublic: options?.defaultPublic ?? defaultPublic,
|
||||
lastEventId: options?.lastEventId,
|
||||
linkPrevious: options?.linkPrevious ?? linkPrevious,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this has a value
|
||||
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
|
||||
const { rundown } = rundownData;
|
||||
|
||||
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
|
||||
if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) {
|
||||
newEvent.linkStart = applicationOptions.lastEventId;
|
||||
} else if (applicationOptions?.lastEventId) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
|
||||
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!;
|
||||
const { rundown } = rundownData;
|
||||
const previousEvent = rundown[applicationOptions.lastEventId];
|
||||
if (isOntimeEvent(previousEvent)) {
|
||||
newEvent.timeStart = previousEvent.timeEnd;
|
||||
newEvent.timeEnd = previousEvent.timeEnd;
|
||||
}
|
||||
}
|
||||
|
||||
if (applicationOptions.defaultPublic) {
|
||||
newEvent.isPublic = true;
|
||||
}
|
||||
|
||||
if (newEvent.duration === undefined && newEvent.timeEnd === undefined) {
|
||||
newEvent.duration = forgivingStringToMillis(defaultDuration);
|
||||
}
|
||||
}
|
||||
|
||||
// handle adding options that concern all event type
|
||||
@@ -95,7 +98,7 @@ export const useEventAction = () => {
|
||||
logAxiosError('Failed adding event', error);
|
||||
}
|
||||
},
|
||||
[_addEventMutation, defaultPublic, queryClient, startTimeIsLastEnd],
|
||||
[_addEventMutation, defaultDuration, defaultPublic, linkPrevious],
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-nocheck -- working on it
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface WebkitDocument extends Document {
|
||||
webkitFullscreenElement?: Element | null;
|
||||
webkitIsFullScreen?: boolean;
|
||||
webkitExitFullscreen?: () => Promise<void>;
|
||||
webkitRequestFullscreen?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function useFullscreen() {
|
||||
const [isFullScreen, setFullScreen] = useState(
|
||||
document.fullscreenElement || (document as WebkitDocument).webkitFullscreenElement,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleChange = () => {
|
||||
if (typeof (document as WebkitDocument).webkitFullscreenElement !== 'undefined') {
|
||||
setFullScreen((document as WebkitDocument).webkitFullscreenElement);
|
||||
} else {
|
||||
setFullScreen(document.fullscreenElement);
|
||||
}
|
||||
};
|
||||
(document as WebkitDocument).addEventListener('webkitfullscreenchange', handleChange, { passive: true });
|
||||
document.addEventListener('fullscreenchange', handleChange, { passive: true });
|
||||
document.addEventListener('resize', handleChange, { passive: true });
|
||||
|
||||
return () => {
|
||||
(document as WebkitDocument).removeEventListener('webkitfullscreenchange', handleChange);
|
||||
document.removeEventListener('fullscreenchange', handleChange);
|
||||
document.removeEventListener('resize', handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleFullScreen = useCallback(() => {
|
||||
if (!document.fullscreenElement && !(document as WebkitDocument).webkitIsFullScreen) {
|
||||
// Fullscreen mode is not active, so we can enter fullscreen mode
|
||||
const element = document.documentElement;
|
||||
if (element.requestFullscreen) {
|
||||
// Standard fullscreen API is supported
|
||||
element.requestFullscreen().catch(() => {
|
||||
/* nothing to do */
|
||||
});
|
||||
} else if (element.webkitRequestFullscreen) {
|
||||
// iOS Safari fullscreen API is supported
|
||||
element.webkitRequestFullscreen?.().catch(() => {
|
||||
/* nothing to do */
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fullscreen mode is active, so we can exit fullscreen mode
|
||||
if (document.exitFullscreen) {
|
||||
// Standard fullscreen API is supported
|
||||
document.exitFullscreen().catch((error) => {
|
||||
console.error('Error while trying to exit fullscreen:', error);
|
||||
});
|
||||
} else if ((document as WebkitDocument).webkitExitFullscreen) {
|
||||
// iOS Safari fullscreen API is supported
|
||||
(document as WebkitDocument).webkitExitFullscreen?.().catch(() => {
|
||||
/* nothing to do */
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { isFullScreen, toggleFullScreen };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export default function useScrollIntoView<T extends HTMLElement>(name: string, location?: string) {
|
||||
const ref = useRef<T>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (location && ref.current) {
|
||||
if (location === name) {
|
||||
ref.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}, [location, name]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
export const viewsSettingsPlaceholder: ViewSettings = {
|
||||
overrideStyles: false,
|
||||
normalColor: '#ffffffcc',
|
||||
warningColor: '#FFAB33',
|
||||
dangerColor: '#ED3333',
|
||||
endMessage: '',
|
||||
freezeEnd: false,
|
||||
normalColor: '#ffffffcc',
|
||||
overrideStyles: false,
|
||||
warningColor: '#FFAB33',
|
||||
};
|
||||
|
||||
@@ -4,35 +4,39 @@ import { booleanFromLocalStorage } from '../utils/localStorage';
|
||||
|
||||
type EditorSettings = {
|
||||
showQuickEntry: boolean;
|
||||
startTimeIsLastEnd: boolean;
|
||||
linkPrevious: boolean;
|
||||
defaultPublic: boolean;
|
||||
defaultDuration: string;
|
||||
};
|
||||
|
||||
type EditorSettingsStore = {
|
||||
eventSettings: EditorSettings;
|
||||
setLocalEventSettings: (newState: EditorSettings) => void;
|
||||
setShowQuickEntry: (showQuickEntry: boolean) => void;
|
||||
setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void;
|
||||
setLinkPrevious: (linkPrevious: boolean) => void;
|
||||
setDefaultPublic: (defaultPublic: boolean) => void;
|
||||
setDefaultDuration: (defaultDuration: string) => void;
|
||||
};
|
||||
|
||||
enum EditorSettingsKeys {
|
||||
ShowQuickEntry = 'ontime-show-quick-entry',
|
||||
StartTimeIsLastEnd = 'ontime-start-is-last-end',
|
||||
LinkPrevious = 'ontime-link-previous',
|
||||
DefaultPublic = 'ontime-default-public',
|
||||
DefaultDuration = 'ontime-default-duration',
|
||||
}
|
||||
|
||||
export const useEditorSettings = create<EditorSettingsStore>((set) => ({
|
||||
eventSettings: {
|
||||
showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false),
|
||||
startTimeIsLastEnd: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
|
||||
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
|
||||
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, true),
|
||||
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, true),
|
||||
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? '00:10:00',
|
||||
},
|
||||
|
||||
setLocalEventSettings: (value) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(value.showQuickEntry));
|
||||
localStorage.setItem(EditorSettingsKeys.StartTimeIsLastEnd, String(value.startTimeIsLastEnd));
|
||||
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(value.linkPrevious));
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(value.defaultPublic));
|
||||
return { eventSettings: value };
|
||||
}),
|
||||
@@ -43,10 +47,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => ({
|
||||
return { eventSettings: { ...state.eventSettings, showQuickEntry } };
|
||||
}),
|
||||
|
||||
setStartTimeIsLastEnd: (startTimeIsLastEnd) =>
|
||||
setLinkPrevious: (linkPrevious) =>
|
||||
set((state) => {
|
||||
localStorage.setItem(EditorSettingsKeys.StartTimeIsLastEnd, String(startTimeIsLastEnd));
|
||||
return { eventSettings: { ...state.eventSettings, startTimeIsLastEnd } };
|
||||
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
|
||||
return { eventSettings: { ...state.eventSettings, linkPrevious } };
|
||||
}),
|
||||
|
||||
setDefaultPublic: (defaultPublic) =>
|
||||
@@ -54,4 +58,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => ({
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
|
||||
return { eventSettings: { ...state.eventSettings, defaultPublic } };
|
||||
}),
|
||||
|
||||
setDefaultDuration: (defaultDuration) =>
|
||||
set((state) => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultDuration, String(defaultDuration));
|
||||
return { eventSettings: { ...state.eventSettings, defaultDuration } };
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Collection of rules for pre-validating a spreadsheet
|
||||
* @param file
|
||||
*/
|
||||
export function validateSpreadsheetImport(file: File) {
|
||||
export function validateExcelImport(file: File) {
|
||||
if (!isExcelFile(file)) {
|
||||
throw new Error('Unknown file type');
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export function validateSpreadsheetImport(file: File) {
|
||||
throw new Error('File is empty');
|
||||
}
|
||||
|
||||
// Limit file size of an excel file to around 10MB
|
||||
// Limit file size of an Excel file to around 10MB
|
||||
if (file.size > 10_000_000) {
|
||||
throw new Error('File size limit (10MB) exceeded');
|
||||
}
|
||||
|
||||
@@ -12,32 +12,27 @@ import ProjectSettingsPanel from './panel/project-settings-panel/ProjectSettings
|
||||
import SourcesPanel from './panel/sources-panel/SourcesPanel';
|
||||
import PanelContent from './panel-content/PanelContent';
|
||||
import PanelList from './panel-list/PanelList';
|
||||
import { useSettingsStore } from './settingsStore';
|
||||
import useAppSettingsNavigation from './useAppSettingsNavigation';
|
||||
|
||||
import style from './AppSettings.module.scss';
|
||||
|
||||
export default function AppSettings() {
|
||||
const setShowSettings = useSettingsStore((state) => state.setShowSettings);
|
||||
const selectedPanel = useSettingsStore((state) => state.showSettings);
|
||||
|
||||
const closeSettings = () => {
|
||||
setShowSettings(null);
|
||||
};
|
||||
useKeyDown(closeSettings, 'Escape');
|
||||
const { close, panel, location } = useAppSettingsNavigation();
|
||||
useKeyDown(close, 'Escape');
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
<ErrorBoundary>
|
||||
<PanelList />
|
||||
<PanelContent onClose={closeSettings}>
|
||||
{selectedPanel === 'project' && <ProjectPanel />}
|
||||
{selectedPanel === 'general' && <GeneralPanel />}
|
||||
{selectedPanel === 'project_settings' && <ProjectSettingsPanel />}
|
||||
{selectedPanel === 'sources' && <SourcesPanel />}
|
||||
{selectedPanel === 'interface' && <InterfacePanel />}
|
||||
{selectedPanel === 'integrations' && <IntegrationsPanel />}
|
||||
{selectedPanel === 'about' && <AboutPanel />}
|
||||
{selectedPanel === 'log' && <LogPanel />}
|
||||
<PanelList selectedPanel={panel} location={location} />
|
||||
<PanelContent onClose={close}>
|
||||
{panel === 'project' && <ProjectPanel location={location} />}
|
||||
{panel === 'general' && <GeneralPanel location={location} />}
|
||||
{panel === 'project_settings' && <ProjectSettingsPanel />}
|
||||
{panel === 'sources' && <SourcesPanel />}
|
||||
{panel === 'interface' && <InterfacePanel />}
|
||||
{panel === 'integrations' && <IntegrationsPanel location={location} />}
|
||||
{panel === 'about' && <AboutPanel />}
|
||||
{panel === 'log' && <LogPanel />}
|
||||
</PanelContent>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.corner {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
right: 2rem;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,4 +58,8 @@ ul {
|
||||
color: $secondary-text-gray;
|
||||
border-left: 1px solid $white-10;
|
||||
font-size: $inner-section-text-size;
|
||||
|
||||
&.active {
|
||||
color: $blue-400;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,18 @@ import { Fragment } from 'react';
|
||||
|
||||
import { isKeyEnter } from '../../../common/utils/keyEvent';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { settingPanels, SettingsOption, useSettingsStore } from '../settingsStore';
|
||||
import { PanelBaseProps, settingPanels, useSettingsStore } from '../settingsStore';
|
||||
import useAppSettingsNavigation from '../useAppSettingsNavigation';
|
||||
|
||||
import style from './PanelList.module.scss';
|
||||
|
||||
export default function PanelList() {
|
||||
const { showSettings, setShowSettings, hasUnsavedChanges } = useSettingsStore();
|
||||
interface PanelListProps extends PanelBaseProps {
|
||||
selectedPanel: string;
|
||||
}
|
||||
|
||||
const handleSelect = (panel: SettingsOption) => {
|
||||
setShowSettings(panel.id);
|
||||
};
|
||||
export default function PanelList({ selectedPanel, location }: PanelListProps) {
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
const { hasUnsavedChanges } = useSettingsStore();
|
||||
|
||||
return (
|
||||
<ul className={style.tabs}>
|
||||
@@ -20,7 +22,7 @@ export default function PanelList() {
|
||||
|
||||
const classes = cx([
|
||||
style.primary,
|
||||
showSettings === panel.id ? style.active : null,
|
||||
selectedPanel === panel.id ? style.active : null,
|
||||
panel.split ? style.split : null,
|
||||
unsaved ? style.unsaved : null,
|
||||
]);
|
||||
@@ -29,9 +31,9 @@ export default function PanelList() {
|
||||
<Fragment key={panel.id}>
|
||||
<li
|
||||
key={panel.id}
|
||||
onClick={() => handleSelect(panel)}
|
||||
onClick={() => setLocation(panel.id)}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && handleSelect(panel);
|
||||
isKeyEnter(event) && setLocation(panel.id);
|
||||
}}
|
||||
className={classes}
|
||||
tabIndex={0}
|
||||
@@ -40,8 +42,18 @@ export default function PanelList() {
|
||||
{panel.label}
|
||||
</li>
|
||||
{panel.secondary?.map((secondary) => {
|
||||
const id = secondary.id.split('__')[1];
|
||||
const secondaryClasses = cx([style.secondary, location === id ? style.active : null]);
|
||||
return (
|
||||
<li key={secondary.id} onClick={() => handleSelect(panel)} className={style.secondary} role='button'>
|
||||
<li
|
||||
key={secondary.id}
|
||||
onClick={() => setLocation(secondary.id)}
|
||||
onKeyDown={(event) => {
|
||||
isKeyEnter(event) && setLocation(secondary.id);
|
||||
}}
|
||||
className={secondaryClasses}
|
||||
role='button'
|
||||
>
|
||||
{secondary.label}
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -75,10 +75,6 @@ $inner-padding: 1rem;
|
||||
box-shadow: 0 1px $white-10;
|
||||
}
|
||||
|
||||
tr {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 400;
|
||||
color: $gray-400;
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { PanelBaseProps } from '../../settingsStore';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import GeneralPanelForm from './GeneralPanelForm';
|
||||
import UrlPresetsForm from './UrlPresetsForm';
|
||||
import ViewSettingsForm from './ViewSettingsForm';
|
||||
|
||||
export default function GeneralPanel() {
|
||||
export default function GeneralPanel({ location }: PanelBaseProps) {
|
||||
const manageRef = useScrollIntoView<HTMLDivElement>('manage', location);
|
||||
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
|
||||
const urlPresetsRef = useScrollIntoView<HTMLDivElement>('urlpresets', location);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Settings</Panel.Header>
|
||||
<GeneralPanelForm />
|
||||
<ViewSettingsForm />
|
||||
<UrlPresetsForm />
|
||||
<div ref={manageRef}>
|
||||
<GeneralPanelForm />
|
||||
</div>
|
||||
<div ref={viewRef}>
|
||||
<ViewSettingsForm />
|
||||
</div>
|
||||
<div ref={urlPresetsRef}>
|
||||
<UrlPresetsForm />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,7 +118,17 @@ export default function ViewSettingsForm() {
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='End message' description='If no end message is provided, timer will continue' />
|
||||
<Panel.Field
|
||||
title='Freeze timer on end'
|
||||
description='Timer in views will stop from going negative after reaching'
|
||||
/>
|
||||
<Switch {...register('freezeEnd')} variant='ontime' size='lg' />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='End message'
|
||||
description='Message to show on negative timers if not frozen. If not provided, timer will continue'
|
||||
/>
|
||||
<Input
|
||||
size='sm'
|
||||
autoComplete='off'
|
||||
|
||||
+12
-3
@@ -1,6 +1,8 @@
|
||||
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
|
||||
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { PanelBaseProps } from '../../settingsStore';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import HttpIntegrations from './HttpIntegrations';
|
||||
@@ -8,7 +10,10 @@ import OscIntegrations from './OscIntegrations';
|
||||
|
||||
const integrationDocsUrl = 'https://docs.getontime.no/api/integrations/';
|
||||
|
||||
export default function IntegrationsPanel() {
|
||||
export default function IntegrationsPanel({ location }: PanelBaseProps) {
|
||||
const oscRef = useScrollIntoView<HTMLDivElement>('osc', location);
|
||||
const httpRef = useScrollIntoView<HTMLDivElement>('http', location);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Integration settings</Panel.Header>
|
||||
@@ -25,8 +30,12 @@ export default function IntegrationsPanel() {
|
||||
</Alert>
|
||||
</Panel.Section>
|
||||
<Panel.Section>
|
||||
<OscIntegrations />
|
||||
<HttpIntegrations />
|
||||
<div ref={oscRef}>
|
||||
<OscIntegrations />
|
||||
</div>
|
||||
<div ref={httpRef}>
|
||||
<HttpIntegrations />
|
||||
</div>
|
||||
</Panel.Section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Switch } from '@chakra-ui/react';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
import { useEditorSettings } from '../../../../common/stores/editorSettings';
|
||||
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
export default function EditorSettingsForm() {
|
||||
const eventSettings = useEditorSettings((state) => state.eventSettings);
|
||||
const setShowQuickEntry = useEditorSettings((state) => state.setShowQuickEntry);
|
||||
const setStartTimeIsLastEnd = useEditorSettings((state) => state.setStartTimeIsLastEnd);
|
||||
const setLinkPrevious = useEditorSettings((state) => state.setLinkPrevious);
|
||||
const setDefaultPublic = useEditorSettings((state) => state.setDefaultPublic);
|
||||
const setDefaultDuration = useEditorSettings((state) => state.setDefaultDuration);
|
||||
|
||||
const durationInMs = forgivingStringToMillis(eventSettings.defaultDuration);
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
@@ -29,14 +34,26 @@ export default function EditorSettingsForm() {
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Start time is last end'
|
||||
description='New events start time will be the previous event end'
|
||||
title='Link previous'
|
||||
description='New events start time will be linked to the previous event'
|
||||
/>
|
||||
<Switch
|
||||
variant='ontime'
|
||||
size='lg'
|
||||
defaultChecked={eventSettings.startTimeIsLastEnd}
|
||||
onChange={(event) => setStartTimeIsLastEnd(event.target.checked)}
|
||||
defaultChecked={eventSettings.linkPrevious}
|
||||
onChange={(event) => setLinkPrevious(event.target.checked)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Default duration'
|
||||
description='When creating a new event, what is the default duration'
|
||||
/>
|
||||
<TimeInput<'defaultDuration'>
|
||||
name='defaultDuration'
|
||||
submitHandler={(_field, value) => setDefaultDuration(value)}
|
||||
time={durationInMs}
|
||||
placeholder='00:10:00'
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHoriz
|
||||
import {
|
||||
deleteProject,
|
||||
downloadCSV,
|
||||
downloadRundown,
|
||||
downloadProject,
|
||||
duplicateProject,
|
||||
loadProject,
|
||||
renameProject,
|
||||
@@ -148,7 +148,7 @@ function ActionMenu({
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
await downloadRundown(filename);
|
||||
await downloadProject(filename);
|
||||
};
|
||||
|
||||
const handleExportCSV = async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.current {
|
||||
background-color: $blue-900;
|
||||
background-color: $blue-1100;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { PanelBaseProps } from '../../settingsStore';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import ManageProjects from './ManageProjects';
|
||||
import ProjectData from './ProjectData';
|
||||
|
||||
export default function ProjectPanel() {
|
||||
export default function ProjectPanel({ location }: PanelBaseProps) {
|
||||
const projectRef = useScrollIntoView<HTMLDivElement>('project', location);
|
||||
const manageRef = useScrollIntoView<HTMLDivElement>('manage', location);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Header>Project</Panel.Header>
|
||||
<ProjectData />
|
||||
<ManageProjects />
|
||||
<div ref={projectRef}>
|
||||
<ProjectData />
|
||||
</div>
|
||||
<div ref={manageRef}>
|
||||
<ManageProjects />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button, Input } from '@chakra-ui/react';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
import { IoShieldCheckmarkOutline } from '@react-icons/all-files/io5/IoShieldCheckmarkOutline';
|
||||
|
||||
import { getWorksheetNames } from '../../../../common/api/sheets';
|
||||
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||
import { openLink } from '../../../../common/utils/linkUtils';
|
||||
import * as Panel from '../PanelUtils';
|
||||
@@ -27,6 +28,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
|
||||
const sheetId = useSheetStore((state) => state.sheetId);
|
||||
const setSheetId = useSheetStore((state) => state.setSheetId);
|
||||
const setWorksheets = useSheetStore((state) => state.setWorksheets);
|
||||
|
||||
const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
|
||||
const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus);
|
||||
@@ -53,7 +55,6 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
};
|
||||
|
||||
const handleCancelFlow = async () => {
|
||||
await handleRevoke();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
@@ -89,6 +90,10 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
if (result?.authenticated) {
|
||||
setAuthenticationStatus(result.authenticated);
|
||||
if (result.authenticated !== 'pending') {
|
||||
if (result.authenticated == 'authenticated') {
|
||||
const names = await getWorksheetNames(result.sheetId);
|
||||
setWorksheets(names);
|
||||
}
|
||||
setLoading('');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -14,10 +14,11 @@ interface ImportReviewProps {
|
||||
rundown: OntimeRundown;
|
||||
customFields: CustomFields;
|
||||
onFinished: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ImportReview(props: ImportReviewProps) {
|
||||
const { rundown, customFields, onFinished } = props;
|
||||
const { rundown, customFields, onFinished, onCancel } = props;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { importRundown } = useGoogleSheet();
|
||||
@@ -25,7 +26,7 @@ export default function ImportReview(props: ImportReviewProps) {
|
||||
|
||||
const handleCancel = () => {
|
||||
resetPreview();
|
||||
onFinished();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const applyImport = async () => {
|
||||
|
||||
@@ -4,9 +4,14 @@ import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
|
||||
import { IoDownloadOutline } from '@react-icons/all-files/io5/IoDownloadOutline';
|
||||
import { ImportMap, unpackError } from 'ontime-utils';
|
||||
|
||||
import { importSpreadsheetPreview } from '../../../../common/api/db';
|
||||
import {
|
||||
getWorksheetNames as getWorksheetNamesExcel,
|
||||
importRundownPreview as importRundownPreviewExcel,
|
||||
upload as uploadExcel,
|
||||
} from '../../../../common/api/excel';
|
||||
import { getWorksheetNames } from '../../../../common/api/sheets';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import { validateSpreadsheetImport } from '../../../../common/utils/uploadUtils';
|
||||
import { validateExcelImport } from '../../../../common/utils/uploadUtils';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import ImportMapForm from './import-map/ImportMapForm';
|
||||
@@ -21,36 +26,43 @@ import style from './SourcesPanel.module.scss';
|
||||
export default function SourcesPanel() {
|
||||
const [importFlow, setImportFlow] = useState<'none' | 'excel' | 'gsheet' | 'finished'>('none');
|
||||
const [error, setError] = useState('');
|
||||
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
|
||||
|
||||
const { exportRundown, importRundownPreview, revoke, verifyAuth } = useGoogleSheet();
|
||||
const { exportRundown, importRundownPreview, verifyAuth } = useGoogleSheet();
|
||||
|
||||
const spreadsheet = useSheetStore((state) => state.spreadsheet);
|
||||
const setSpreadsheet = useSheetStore((state) => state.setSpreadsheet);
|
||||
const setWorksheets = useSheetStore((state) => state.setWorksheets);
|
||||
const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
|
||||
const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus);
|
||||
const rundown = useSheetStore((state) => state.rundown);
|
||||
const setRundown = useSheetStore((state) => state.setRundown);
|
||||
const customFields = useSheetStore((state) => state.customFields);
|
||||
const setCustomFields = useSheetStore((state) => state.setCustomFields);
|
||||
const setSheetId = useSheetStore((state) => state.setSheetId);
|
||||
const sheetId = useSheetStore((state) => state.sheetId);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFile = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const handleFile = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const fileToUpload = event.target.files?.[0];
|
||||
|
||||
if (!fileToUpload) {
|
||||
setSpreadsheet(null);
|
||||
setWorksheets(null);
|
||||
setHasFile('none');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
validateSpreadsheetImport(fileToUpload);
|
||||
setSpreadsheet(fileToUpload);
|
||||
setHasFile('loading');
|
||||
validateExcelImport(fileToUpload);
|
||||
await uploadExcel(fileToUpload);
|
||||
const names = await getWorksheetNamesExcel();
|
||||
setWorksheets(names);
|
||||
setImportFlow('excel');
|
||||
setHasFile('done');
|
||||
} catch (error) {
|
||||
const errorMessage = unpackError(error);
|
||||
setError(`Error uploading file: ${errorMessage}`);
|
||||
setSpreadsheet(null);
|
||||
setWorksheets(null);
|
||||
setHasFile('none');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -58,7 +70,16 @@ export default function SourcesPanel() {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const openGSheetFlow = () => {
|
||||
const openGSheetFlow = async () => {
|
||||
const result = await verifyAuth();
|
||||
if (result) {
|
||||
setAuthenticationStatus(result.authenticated);
|
||||
setSheetId(result.sheetId);
|
||||
if (result.authenticated === 'authenticated' && result.sheetId) {
|
||||
const names = await getWorksheetNames(result.sheetId);
|
||||
setWorksheets(names);
|
||||
}
|
||||
}
|
||||
setImportFlow('gsheet');
|
||||
};
|
||||
|
||||
@@ -68,9 +89,8 @@ export default function SourcesPanel() {
|
||||
|
||||
const handleSubmitImportPreview = async (importMap: ImportMap) => {
|
||||
if (importFlow === 'excel') {
|
||||
if (!spreadsheet) return;
|
||||
try {
|
||||
const previewData = await importSpreadsheetPreview(spreadsheet, importMap);
|
||||
const previewData = await importRundownPreviewExcel(importMap);
|
||||
setRundown(previewData.rundown);
|
||||
setCustomFields(previewData.customFields);
|
||||
} catch (error) {
|
||||
@@ -86,12 +106,9 @@ export default function SourcesPanel() {
|
||||
|
||||
const cancelImportMap = async () => {
|
||||
setImportFlow('none');
|
||||
if (spreadsheet) {
|
||||
setSpreadsheet(null);
|
||||
}
|
||||
|
||||
setHasFile('none');
|
||||
setWorksheets(null);
|
||||
if (authenticationStatus === 'authenticated') {
|
||||
await revoke();
|
||||
const result = await verifyAuth();
|
||||
if (result) {
|
||||
setAuthenticationStatus(result.authenticated);
|
||||
@@ -102,7 +119,8 @@ export default function SourcesPanel() {
|
||||
const handleFinished = () => {
|
||||
setImportFlow('finished');
|
||||
setRundown(null);
|
||||
setSpreadsheet(null);
|
||||
setHasFile('none');
|
||||
setWorksheets(null);
|
||||
setCustomFields(null);
|
||||
};
|
||||
|
||||
@@ -113,12 +131,11 @@ export default function SourcesPanel() {
|
||||
|
||||
const isExcelFlow = importFlow === 'excel';
|
||||
const isGSheetFlow = importFlow === 'gsheet';
|
||||
const hasFile = Boolean(spreadsheet);
|
||||
const isAuthenticated = authenticationStatus === 'authenticated';
|
||||
const showInput = importFlow === 'none';
|
||||
const showSuccess = importFlow === 'finished';
|
||||
const showAuth = isGSheetFlow && !isAuthenticated;
|
||||
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile);
|
||||
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
|
||||
const showReview = rundown !== null && customFields !== null;
|
||||
|
||||
return (
|
||||
@@ -141,13 +158,25 @@ export default function SourcesPanel() {
|
||||
/>
|
||||
<div className={style.uploadSection}>
|
||||
<div>
|
||||
<Button variant='ontime-filled' size='sm' leftIcon={<IoDownloadOutline />} onClick={handleUpload}>
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
leftIcon={<IoDownloadOutline />}
|
||||
onClick={handleUpload}
|
||||
isLoading={hasFile === 'loading'}
|
||||
>
|
||||
Import from spreadsheet
|
||||
</Button>
|
||||
<Panel.Description>Accepts .xlsx files</Panel.Description>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant='ontime-filled' size='sm' leftIcon={<IoCloudOutline />} onClick={openGSheetFlow}>
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
leftIcon={<IoCloudOutline />}
|
||||
onClick={openGSheetFlow}
|
||||
isDisabled={hasFile !== 'none'}
|
||||
>
|
||||
Synchronise with Google
|
||||
</Button>
|
||||
<Panel.Description>Start authentication process</Panel.Description>
|
||||
@@ -172,7 +201,14 @@ export default function SourcesPanel() {
|
||||
onSubmitImport={handleSubmitImportPreview}
|
||||
/>
|
||||
)}
|
||||
{showReview && <ImportReview rundown={rundown} customFields={customFields} onFinished={handleFinished} />}
|
||||
{showReview && (
|
||||
<ImportReview
|
||||
rundown={rundown}
|
||||
customFields={customFields}
|
||||
onFinished={handleFinished}
|
||||
onCancel={cancelImportMap}
|
||||
/>
|
||||
)}
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
</>
|
||||
|
||||
+41
-2
@@ -1,12 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { Button, IconButton, Input } from '@chakra-ui/react';
|
||||
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { isAlphanumeric } from '../../../../../common/utils/regex';
|
||||
import * as Panel from '../../PanelUtils';
|
||||
import useGoogleSheet from '../useGoogleSheet';
|
||||
import { useSheetStore } from '../useSheetStore';
|
||||
|
||||
import { convertToImportMap, getPersistedOptions, NamedImportMap, persistImportMap } from './importMapUtils';
|
||||
@@ -23,7 +24,7 @@ interface ImportMapFormProps {
|
||||
export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
const { isSpreadsheet, onCancel, onSubmitExport, onSubmitImport } = props;
|
||||
const namedImportMap = getPersistedOptions();
|
||||
|
||||
const { revoke } = useGoogleSheet();
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
@@ -41,6 +42,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
});
|
||||
|
||||
const stepData = useSheetStore((state) => state.stepData);
|
||||
const worksheetNames = useSheetStore((state) => state.worksheetNames);
|
||||
|
||||
const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
|
||||
|
||||
@@ -52,6 +54,11 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
setLoading('');
|
||||
};
|
||||
|
||||
const handleRevoke = async () => {
|
||||
await revoke();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleImportPreview = async (values: NamedImportMap) => {
|
||||
setLoading('import');
|
||||
const importMap = convertToImportMap(values);
|
||||
@@ -78,6 +85,13 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
<Panel.Title>
|
||||
Import options
|
||||
<div className={style.buttonRow}>
|
||||
{!isSpreadsheet && (
|
||||
<Tooltip label='Revoke the google authentication'>
|
||||
<Button variant='ontime-subtle' size='sm' onClick={handleRevoke} isDisabled={isLoading}>
|
||||
Revoke
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button variant='ontime-subtle' size='sm' onClick={onCancel} isDisabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -116,6 +130,31 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
if (label === 'custom') {
|
||||
return null;
|
||||
}
|
||||
if (label === 'Worksheet') {
|
||||
return (
|
||||
<tr key={importName as string}>
|
||||
<td>{label}</td>
|
||||
<td>
|
||||
<Select
|
||||
variant='ontime'
|
||||
id={importName as string}
|
||||
size='sm'
|
||||
{...register(label as keyof NamedImportMap)}
|
||||
>
|
||||
{worksheetNames &&
|
||||
worksheetNames.map((name) => {
|
||||
return (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</td>
|
||||
<td className={style.singleActionCell} />
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<tr key={importName as string}>
|
||||
<td>{label}</td>
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function useGoogleSheet() {
|
||||
const setCustomFields = useSheetStore((state) => state.setCustomFields);
|
||||
|
||||
/** whether the current session has been authenticated */
|
||||
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus } | void> => {
|
||||
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => {
|
||||
try {
|
||||
return verifyAuthenticationStatus();
|
||||
} catch (_error) {
|
||||
|
||||
@@ -5,13 +5,12 @@ import { create } from 'zustand';
|
||||
type SheetStore = {
|
||||
stepData: typeof initialStepData;
|
||||
patchStepData: (patch: Partial<typeof initialStepData>) => void;
|
||||
setWorksheets: (worksheetNames: string[] | null) => void;
|
||||
worksheetNames: string[] | null;
|
||||
|
||||
spreadsheet: File | null;
|
||||
setSpreadsheet: (spreadsheet: File | null) => void;
|
||||
|
||||
//gSheet
|
||||
sheetId: string | null;
|
||||
setSheetId: (sheetId: string | null) => void;
|
||||
|
||||
authenticationStatus: AuthenticationStatus;
|
||||
setAuthenticationStatus: (status: AuthenticationStatus) => void;
|
||||
|
||||
@@ -39,7 +38,7 @@ const initialStepData = {
|
||||
|
||||
const initialState = {
|
||||
stepData: initialStepData,
|
||||
spreadsheet: null,
|
||||
worksheetNames: null,
|
||||
sheetId: null,
|
||||
authenticationStatus: 'not_authenticated' as AuthenticationStatus,
|
||||
rundown: null,
|
||||
@@ -55,7 +54,7 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
|
||||
set({ stepData: { ...stepData, ...patch } });
|
||||
},
|
||||
|
||||
setSpreadsheet: (spreadsheet: File | null) => set({ spreadsheet }),
|
||||
setWorksheets: (worksheetNames: string[] | null) => set({ worksheetNames }),
|
||||
|
||||
setSheetId: (sheetId: string | null) => set({ sheetId }),
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export const settingPanels: Readonly<SettingsOption[]> = [
|
||||
secondary: [
|
||||
{ id: 'general__manage', label: 'Manage Ontime settings' },
|
||||
{ id: 'general__view', label: 'View settings' },
|
||||
{ id: 'general__urlPresets', label: 'URL presets' },
|
||||
{ id: 'general__urlpresets', label: 'URL presets' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -54,11 +54,11 @@ export const settingPanels: Readonly<SettingsOption[]> = [
|
||||
] as const;
|
||||
|
||||
export type SettingsOptionId = (typeof settingPanels)[number]['id'];
|
||||
const firstPanel = settingPanels[0].id;
|
||||
export interface PanelBaseProps {
|
||||
location?: string;
|
||||
}
|
||||
|
||||
type SettingsStore = {
|
||||
showSettings: SettingsOptionId | null;
|
||||
setShowSettings: (panelId?: SettingsOptionId | null) => void;
|
||||
unsavedChanges: Set<SettingsOptionId>;
|
||||
hasUnsavedChanges: (panelId: SettingsOptionId) => boolean;
|
||||
addUnsavedChanges: (panelId: SettingsOptionId) => void;
|
||||
@@ -66,16 +66,6 @@ type SettingsStore = {
|
||||
};
|
||||
|
||||
export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
showSettings: null,
|
||||
setShowSettings: (panelId?: SettingsOptionId | null) => {
|
||||
const newPanel = panelId === undefined ? firstPanel : panelId;
|
||||
set((state) => {
|
||||
return {
|
||||
...state,
|
||||
showSettings: newPanel,
|
||||
};
|
||||
});
|
||||
},
|
||||
unsavedChanges: new Set(),
|
||||
hasUnsavedChanges: (panelId: SettingsOptionId) => get().unsavedChanges.has(panelId),
|
||||
addUnsavedChanges: (panelId: SettingsOptionId) =>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { SettingsOptionId } from './settingsStore';
|
||||
|
||||
const settingsKey = 'settings';
|
||||
|
||||
export default function useAppSettingsNavigation() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const selectedPanel = useMemo(
|
||||
() => (searchParams.get(settingsKey) as SettingsOptionId | null) ?? 'project',
|
||||
[searchParams],
|
||||
);
|
||||
const isOpen = useMemo(() => Boolean(searchParams.get(settingsKey)), [searchParams]);
|
||||
const [panel, location] = selectedPanel.split('__');
|
||||
|
||||
const close = useCallback(() => {
|
||||
searchParams.delete(settingsKey);
|
||||
setSearchParams(searchParams);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const setLocation = useCallback(
|
||||
(panelId: SettingsOptionId) => {
|
||||
searchParams.set(settingsKey, panelId);
|
||||
setSearchParams(searchParams);
|
||||
},
|
||||
[searchParams, setSearchParams],
|
||||
);
|
||||
|
||||
return { isOpen, panel, location, setLocation, close };
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import InputRow from './InputRow';
|
||||
|
||||
import style from './MessageControl.module.scss';
|
||||
|
||||
const noop = () => undefined;
|
||||
|
||||
export default function MessageControl() {
|
||||
const message = useMessageControl();
|
||||
const blink = message.timer.blink;
|
||||
@@ -64,13 +66,13 @@ export default function MessageControl() {
|
||||
</Button>
|
||||
</div>
|
||||
<InputRow
|
||||
label='External Message'
|
||||
label='External Message (readonly)'
|
||||
placeholder={enDash}
|
||||
readonly
|
||||
text={message.external.text || ''}
|
||||
visible={message.external.visible || false}
|
||||
changeHandler={() => undefined}
|
||||
actionHandler={() => undefined}
|
||||
changeHandler={noop}
|
||||
actionHandler={noop}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -86,16 +86,17 @@
|
||||
grid-area: 2 / 2 / 2 / 4 ;
|
||||
}
|
||||
|
||||
.tag {
|
||||
color: $label-gray;
|
||||
font-size: calc(1rem - 2px);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: $section-white;
|
||||
font-size: $text-body-size;
|
||||
}
|
||||
|
||||
.tag {
|
||||
color: $label-gray;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rolltag {
|
||||
color: $ontime-roll;
|
||||
font-size: $text-body-size;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { Playback } from 'ontime-types';
|
||||
import { millisToMinutes, millisToSeconds, millisToString } from 'ontime-utils';
|
||||
import { dayInMs, millisToMinutes, millisToSeconds, millisToString } from 'ontime-utils';
|
||||
|
||||
import { setPlayback, useTimer } from '../../../../common/hooks/useSocket';
|
||||
import { tooltipDelayMid } from '../../../../ontimeConfig';
|
||||
@@ -17,9 +17,10 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
|
||||
const { playback } = props;
|
||||
const timer = useTimer();
|
||||
|
||||
// TODO: checkout typescript in utilities
|
||||
const started = millisToString(timer.startedAt);
|
||||
const finish = millisToString(timer.expectedFinish);
|
||||
const expectedFinish = timer.expectedFinish !== null ? timer.expectedFinish % dayInMs : null;
|
||||
const finish = millisToString(expectedFinish);
|
||||
|
||||
const isRolling = playback === Playback.Roll;
|
||||
const isStopped = playback === Playback.Stop;
|
||||
const isWaiting = timer.secondaryTimer !== null && timer.secondaryTimer > 0 && timer.current === null;
|
||||
@@ -72,11 +73,11 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
|
||||
) : (
|
||||
<>
|
||||
<div className={style.start}>
|
||||
<span className={style.tag}>Started at </span>
|
||||
<span className={style.tag}>Started at</span>
|
||||
<span className={style.time}>{started}</span>
|
||||
</div>
|
||||
<div className={style.finish}>
|
||||
<span className={style.tag}>Finish at </span>
|
||||
<span className={style.tag}>Expect end</span>
|
||||
<span className={style.time}>{finish}</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { useFullscreen } from '@mantine/hooks';
|
||||
import { IoContract } from '@react-icons/all-files/io5/IoContract';
|
||||
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
||||
import { IoLocate } from '@react-icons/all-files/io5/IoLocate';
|
||||
@@ -6,7 +7,6 @@ import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline'
|
||||
import { Playback, ProjectData } from 'ontime-types';
|
||||
|
||||
import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon';
|
||||
import useFullscreen from '../../../common/hooks/useFullscreen';
|
||||
import useProjectData from '../../../common/hooks-query/useProjectData';
|
||||
import { cx, enDash } from '../../../common/utils/styleUtils';
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
@@ -31,7 +31,7 @@ export default function CuesheetTableHeader({ handleExport, featureData }: Cuesh
|
||||
const showSettings = useCuesheetSettings((state) => state.showSettings);
|
||||
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
|
||||
const toggleFollow = useCuesheetSettings((state) => state.toggleFollow);
|
||||
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
||||
const { fullscreen, toggle } = useFullscreen();
|
||||
const { data: project } = useProjectData();
|
||||
|
||||
const exportProject = () => {
|
||||
@@ -75,8 +75,8 @@ export default function CuesheetTableHeader({ handleExport, featureData }: Cuesh
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Toggle Fullscreen'>
|
||||
<span onClick={() => toggleFullScreen()} className={style.actionIcon}>
|
||||
{isFullScreen ? <IoContract /> : <IoExpand />}
|
||||
<span onClick={() => toggle()} className={style.actionIcon}>
|
||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Export rundown'>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { lazy, useEffect } from 'react';
|
||||
|
||||
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
||||
import AppSettings from '../app-settings/AppSettings';
|
||||
import { SettingsOptionId, useSettingsStore } from '../app-settings/settingsStore';
|
||||
import { SettingsOptionId } from '../app-settings/settingsStore';
|
||||
import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation';
|
||||
import MenuBar from '../menu/MenuBar';
|
||||
import Overview from '../overview/Overview';
|
||||
|
||||
@@ -13,11 +14,14 @@ const TimerControl = lazy(() => import('../control/playback/TimerControlExport')
|
||||
const MessageControl = lazy(() => import('../control/message/MessageControlExport'));
|
||||
|
||||
export default function Editor() {
|
||||
const showSettings = useSettingsStore((state) => state.showSettings);
|
||||
const setShowSettings = useSettingsStore((state) => state.setShowSettings);
|
||||
const { isOpen, setLocation, close } = useAppSettingsNavigation();
|
||||
|
||||
const handleSettings = (newTab?: SettingsOptionId) => {
|
||||
setShowSettings(newTab);
|
||||
if (isOpen) {
|
||||
close();
|
||||
} else {
|
||||
setLocation(newTab ?? 'project');
|
||||
}
|
||||
};
|
||||
|
||||
// Set window title
|
||||
@@ -25,14 +29,12 @@ export default function Editor() {
|
||||
document.title = 'ontime - Editor';
|
||||
}, []);
|
||||
|
||||
const isSettingsOpen = Boolean(showSettings);
|
||||
|
||||
return (
|
||||
<div className={styles.mainContainer} data-testid='event-editor'>
|
||||
<ErrorBoundary>
|
||||
<MenuBar openSettings={handleSettings} isSettingsOpen={isSettingsOpen} />
|
||||
<MenuBar openSettings={handleSettings} isSettingsOpen={isOpen} />
|
||||
</ErrorBoundary>
|
||||
{showSettings ? (
|
||||
{isOpen ? (
|
||||
<AppSettings />
|
||||
) : (
|
||||
<div id='panels' className={styles.panelContainer}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
import { dayInMs, millisToString } from 'ontime-utils';
|
||||
|
||||
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
|
||||
import { useRuntimeOverview, useRuntimePlaybackOverview } from '../../common/hooks/useSocket';
|
||||
@@ -19,9 +20,27 @@ function formatedTime(time: MaybeNumber) {
|
||||
return millisToString(time, { fallback: timerPlaceholder });
|
||||
}
|
||||
|
||||
function calculateEndAndDaySpan(end: MaybeNumber): [MaybeNumber, number] {
|
||||
let maybeEnd = end;
|
||||
let maybeDaySpan = 0;
|
||||
if (end !== null) {
|
||||
if (end > dayInMs) {
|
||||
maybeEnd = end % dayInMs;
|
||||
maybeDaySpan = Math.floor(end / dayInMs);
|
||||
}
|
||||
}
|
||||
return [maybeEnd, maybeDaySpan];
|
||||
}
|
||||
|
||||
export default function Overview() {
|
||||
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview();
|
||||
|
||||
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
|
||||
const plannedEndText = formatedTime(maybePlannedEnd);
|
||||
|
||||
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
|
||||
const expectedEndText = formatedTime(maybeExpectedEnd);
|
||||
|
||||
return (
|
||||
<div className={style.overview}>
|
||||
<ErrorBoundary>
|
||||
@@ -32,8 +51,8 @@ export default function Overview() {
|
||||
</div>
|
||||
<RuntimeOverview />
|
||||
<div className={style.column}>
|
||||
<TimeRow label='Planned end' value={formatedTime(plannedEnd)} className={style.end} />
|
||||
<TimeRow label='Expected end' value={formatedTime(expectedEnd)} className={style.end} />
|
||||
<TimeRow label='Planned end' value={plannedEndText} className={style.end} daySpan={maybePlannedDaySpan} />
|
||||
<TimeRow label='Expected end' value={expectedEndText} className={style.end} daySpan={maybeExpectedDaySpan} />
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
@@ -20,8 +20,7 @@
|
||||
flex-direction: column;
|
||||
|
||||
.label {
|
||||
line-height: 0.9em;
|
||||
|
||||
line-height: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +28,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
height: 2.25em;
|
||||
|
||||
.label {
|
||||
text-align: right;
|
||||
@@ -38,3 +38,12 @@
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.daySpan {
|
||||
&::after {
|
||||
content: "*";
|
||||
vertical-align: super;
|
||||
font-size: 0.75em;
|
||||
color: $blue-500;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import style from './TimeLayout.module.scss';
|
||||
@@ -5,6 +7,7 @@ import style from './TimeLayout.module.scss';
|
||||
interface TimeLayoutProps {
|
||||
label: string;
|
||||
value: string;
|
||||
daySpan?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -17,11 +20,17 @@ export function TimeColumn({ label, value, className }: TimeLayoutProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function TimeRow({ label, value, className }: TimeLayoutProps) {
|
||||
export function TimeRow({ label, value, daySpan, className }: TimeLayoutProps) {
|
||||
return (
|
||||
<div className={style.row}>
|
||||
<span className={style.label}>{label}</span>
|
||||
<span className={cx([style.clock, className])}>{value}</span>
|
||||
{daySpan ? (
|
||||
<Tooltip label={`Event spans over ${daySpan + 1} days`}>
|
||||
<span className={cx([style.clock, style.daySpan, className])}>{value}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className={cx([style.clock, className])}>{value}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,15 +38,14 @@ export default function Rundown({ data }: RundownProps) {
|
||||
const { addEvent, reorderEvent } = useEventAction();
|
||||
const eventSettings = useEditorSettings((state) => state.eventSettings);
|
||||
const defaultPublic = eventSettings.defaultPublic;
|
||||
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
|
||||
const linkPrevious = eventSettings.linkPrevious;
|
||||
const showQuickEntry = eventSettings.showQuickEntry;
|
||||
|
||||
// cursor
|
||||
const { cursor, mode: appMode, setCursor } = useAppMode();
|
||||
const viewFollowsCursor = appMode === AppMode.Run;
|
||||
const cursorRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: true });
|
||||
useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: appMode === AppMode.Run });
|
||||
|
||||
// DND KIT
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
@@ -74,17 +73,17 @@ export default function Rundown({ data }: RundownProps) {
|
||||
type: SupportedEvent.Event,
|
||||
};
|
||||
const options = {
|
||||
defaultPublic,
|
||||
startTimeIsLastEnd,
|
||||
lastEventId: cursor,
|
||||
after: cursor,
|
||||
defaultPublic,
|
||||
lastEventId: cursor,
|
||||
linkPrevious,
|
||||
};
|
||||
addEvent(newEvent, options);
|
||||
} else {
|
||||
addEvent({ type }, { after: cursor });
|
||||
}
|
||||
},
|
||||
[addEvent, rundown, defaultPublic, startTimeIsLastEnd],
|
||||
[addEvent, rundown, defaultPublic, linkPrevious],
|
||||
);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
@@ -183,11 +182,11 @@ export default function Rundown({ data }: RundownProps) {
|
||||
|
||||
useEffect(() => {
|
||||
// in run mode, we follow selection
|
||||
if (!viewFollowsCursor || !featureData?.selectedEventId) {
|
||||
if (appMode !== AppMode.Run || !featureData?.selectedEventId) {
|
||||
return;
|
||||
}
|
||||
// moveCursorTo(featureData.selectedEventId);
|
||||
}, [featureData?.selectedEventId, viewFollowsCursor]);
|
||||
setCursor(featureData.selectedEventId);
|
||||
}, [appMode, featureData.selectedEventId, setCursor]);
|
||||
|
||||
const handleOnDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
@@ -46,22 +46,24 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
} = props;
|
||||
const { emitError } = useEmitLog();
|
||||
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
|
||||
const { cursor } = useAppMode();
|
||||
const cursor = useAppMode((state) => state.cursor);
|
||||
const setCursor = useAppMode((state) => state.setCursor);
|
||||
const { selectedEvents, clearSelectedEvents } = useEventSelection();
|
||||
|
||||
const eventSettings = useEditorSettings((state) => state.eventSettings);
|
||||
const defaultPublic = eventSettings.defaultPublic;
|
||||
const linkPrevious = eventSettings.linkPrevious;
|
||||
|
||||
const removeOpenEvent = useCallback(() => {
|
||||
if (selectedEvents.has(data.id)) {
|
||||
clearSelectedEvents();
|
||||
}
|
||||
|
||||
// clear cursor if we are deleting the event that is currently selected
|
||||
if (cursor === data.id) {
|
||||
// setCursor(null);
|
||||
setCursor(null);
|
||||
}
|
||||
}, [cursor, data.id, selectedEvents, clearSelectedEvents]);
|
||||
|
||||
const eventSettings = useEditorSettings((state) => state.eventSettings);
|
||||
const defaultPublic = eventSettings.defaultPublic;
|
||||
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
|
||||
}, [selectedEvents, data.id, cursor, clearSelectedEvents, setCursor]);
|
||||
|
||||
// Create / delete new events
|
||||
type FieldValue = {
|
||||
@@ -74,10 +76,10 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
case 'event': {
|
||||
const newEvent = { type: SupportedEvent.Event };
|
||||
const options = {
|
||||
startTimeIsLastEnd,
|
||||
after: data.id,
|
||||
defaultPublic,
|
||||
lastEventId: previousEventId,
|
||||
after: data.id,
|
||||
linkPrevious,
|
||||
};
|
||||
return addEvent(newEvent, options);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
import { millisToString, removeLeadingZero, removeTrailingZero } from 'ontime-utils';
|
||||
import { dayInMs, millisToString, removeLeadingZero, removeTrailingZero } from 'ontime-utils';
|
||||
|
||||
export function formatDelay(timeStart: number, delay: number): string | undefined {
|
||||
if (!delay) return;
|
||||
@@ -23,10 +23,13 @@ export function formatOverlap(
|
||||
|
||||
if (previousStart && timeStart < previousEnd) {
|
||||
const overlap = timeEnd - previousStart;
|
||||
if (overlap <= 0) return;
|
||||
|
||||
const overlapString = removeLeadingZero(millisToString(Math.abs(overlap)));
|
||||
return `Overlap ${overlapString}`;
|
||||
if (overlap > 0) {
|
||||
const overlapString = removeLeadingZero(millisToString(Math.abs(overlap)));
|
||||
return `Overlap ${overlapString}`;
|
||||
}
|
||||
const gap = timeStart + dayInMs - previousEnd;
|
||||
const gapString = removeLeadingZero(millisToString(Math.abs(gap)));
|
||||
return `Gap ${gapString} (next day)`;
|
||||
}
|
||||
|
||||
const overlapString = removeLeadingZero(millisToString(Math.abs(overlap)));
|
||||
|
||||
@@ -20,20 +20,29 @@ describe('formatOverlap()', () => {
|
||||
});
|
||||
|
||||
it('handles events the day after, without overlap', () => {
|
||||
const previousStart = new Date(0).setUTCHours(11).valueOf();
|
||||
const previousEnd = new Date(0).setUTCHours(12).valueOf();
|
||||
const timeStart = new Date(0).setUTCHours(6).valueOf();
|
||||
const timeEnd = new Date(0).setUTCHours(10).valueOf();
|
||||
const previousStart = new Date(0).setUTCHours(11);
|
||||
const previousEnd = new Date(0).setUTCHours(12);
|
||||
const timeStart = new Date(0).setUTCHours(6);
|
||||
const timeEnd = new Date(0).setUTCHours(10);
|
||||
const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
|
||||
expect(result).toBeUndefined();
|
||||
expect(result).toBe('Gap 18:00:00 (next day)');
|
||||
});
|
||||
|
||||
it('handles events the day after, with overlap', () => {
|
||||
const previousStart = new Date(0).setUTCHours(9).valueOf();
|
||||
const previousEnd = new Date(0).setUTCHours(10).valueOf();
|
||||
const timeStart = new Date(0).setUTCHours(6).valueOf();
|
||||
const timeEnd = new Date(0).setUTCHours(11).valueOf();
|
||||
const previousStart = new Date(0).setUTCHours(9);
|
||||
const previousEnd = new Date(0).setUTCHours(10);
|
||||
const timeStart = new Date(0).setUTCHours(6);
|
||||
const timeEnd = new Date(0).setUTCHours(11);
|
||||
const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
|
||||
expect(result).toBe('Overlap 02:00:00');
|
||||
});
|
||||
|
||||
it('handles events the day after, with gap', () => {
|
||||
const previousStart = new Date(0).setUTCHours(17);
|
||||
const previousEnd = new Date(0).setUTCHours(23);
|
||||
const timeStart = new Date(0).setUTCHours(9);
|
||||
const timeEnd = new Date(0).setUTCHours(11);
|
||||
const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
|
||||
expect(result).toBe('Gap 10:00:00 (next day)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CSSProperties, useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types';
|
||||
|
||||
@@ -25,6 +26,7 @@ export default function EventEditor() {
|
||||
const { data: customFields } = useCustomFields();
|
||||
const { order, rundown } = data;
|
||||
const { updateEvent } = useEventAction();
|
||||
const [_searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [event, setEvent] = useState<OntimeEvent | null>(null);
|
||||
|
||||
@@ -60,6 +62,10 @@ export default function EventEditor() {
|
||||
[event?.id, updateEvent],
|
||||
);
|
||||
|
||||
const handleOpenCustomManager = () => {
|
||||
setSearchParams({ settings: 'project_settings__custom' });
|
||||
};
|
||||
|
||||
if (!event) {
|
||||
return (
|
||||
<div className={style.eventEditor} data-testid='editor-container'>
|
||||
@@ -98,7 +104,7 @@ export default function EventEditor() {
|
||||
<div className={style.column}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span>Custom Fields</span>
|
||||
<Button variant='ontime-subtle' size='sm' isDisabled>
|
||||
<Button variant='ontime-subtle' size='sm' onClick={handleOpenCustomManager}>
|
||||
Manage
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -23,26 +23,24 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
|
||||
const { addEvent } = useEventAction();
|
||||
const { emitError } = useEmitLog();
|
||||
|
||||
const doStartTime = useRef<HTMLInputElement | null>(null);
|
||||
const doLinkPrevious = useRef<HTMLInputElement | null>(null);
|
||||
const doPublic = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const eventSettings = useEditorSettings((state) => state.eventSettings);
|
||||
const defaultPublic = eventSettings.defaultPublic;
|
||||
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
|
||||
const { defaultPublic, linkPrevious } = useEditorSettings((state) => state.eventSettings);
|
||||
|
||||
const handleCreateEvent = useCallback(
|
||||
(eventType: SupportedEvent) => {
|
||||
switch (eventType) {
|
||||
case 'event': {
|
||||
const isPublicOption = doPublic?.current?.checked;
|
||||
const startTimeIsLastEndOption = doStartTime?.current?.checked;
|
||||
const defaultPublic = doPublic?.current?.checked;
|
||||
const linkPrevious = doLinkPrevious?.current?.checked;
|
||||
|
||||
const newEvent = { type: SupportedEvent.Event };
|
||||
const options = {
|
||||
defaultPublic: isPublicOption,
|
||||
startTimeIsLastEnd: startTimeIsLastEndOption,
|
||||
lastEventId: previousEventId,
|
||||
after: previousEventId,
|
||||
defaultPublic,
|
||||
lastEventId: previousEventId,
|
||||
linkPrevious,
|
||||
};
|
||||
addEvent(newEvent, options);
|
||||
break;
|
||||
@@ -115,8 +113,8 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={style.options}>
|
||||
<Checkbox ref={doStartTime} size='sm' variant='ontime-ondark' defaultChecked={startTimeIsLastEnd}>
|
||||
Start time is last end
|
||||
<Checkbox ref={doLinkPrevious} size='sm' variant='ontime-ondark' defaultChecked={linkPrevious}>
|
||||
Link to previous
|
||||
</Checkbox>
|
||||
<Checkbox ref={doPublic} size='sm' variant='ontime-ondark' defaultChecked={defaultPublic}>
|
||||
Event is public
|
||||
|
||||
@@ -7,11 +7,12 @@ import { IoTrashOutline } from '@react-icons/all-files/io5/IoTrashOutline';
|
||||
import { SupportedEvent } from 'ontime-types';
|
||||
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { useAppMode } from '../../../common/stores/appModeStore';
|
||||
import { useEventSelection } from '../useEventSelection';
|
||||
|
||||
const RundownMenu = ({ children }: { children: ReactNode }) => {
|
||||
const { clearSelectedEvents } = useEventSelection();
|
||||
|
||||
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
|
||||
const setCursor = useAppMode((state) => state.setCursor);
|
||||
const { addEvent, deleteAllEvents } = useEventAction();
|
||||
|
||||
const newEvent = useCallback(() => {
|
||||
@@ -29,8 +30,8 @@ const RundownMenu = ({ children }: { children: ReactNode }) => {
|
||||
const deleteAll = useCallback(() => {
|
||||
deleteAllEvents();
|
||||
clearSelectedEvents();
|
||||
// setCursor(null);
|
||||
}, [deleteAllEvents, clearSelectedEvents]);
|
||||
setCursor(null);
|
||||
}, [clearSelectedEvents, deleteAllEvents, setCursor]);
|
||||
|
||||
return (
|
||||
<Menu isLazy lazyBehavior='unmount' variant='ontime-on-dark' placement='right-start'>
|
||||
|
||||
@@ -109,12 +109,7 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
|
||||
|
||||
{overMidnight && (
|
||||
<div className={style.timerNote}>
|
||||
<Tooltip
|
||||
label='Over midnight: end time is before start'
|
||||
openDelay={tooltipDelayFast}
|
||||
variant='ontime-ondark'
|
||||
shouldWrapChildren
|
||||
>
|
||||
<Tooltip label='Over midnight' openDelay={tooltipDelayFast} variant='ontime-ondark' shouldWrapChildren>
|
||||
<IoAlertCircleOutline />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ViewExtendedTimer } from '../../../common/models/TimeManager.type'
|
||||
|
||||
type TimerTypeParams = Pick<ViewExtendedTimer, 'timerType' | 'current' | 'elapsed' | 'clock'>;
|
||||
|
||||
export function getTimerByType(timerObject?: TimerTypeParams): number | null {
|
||||
export function getTimerByType(freezeEnd: boolean, timerObject?: TimerTypeParams): number | null {
|
||||
if (!timerObject) {
|
||||
return null;
|
||||
}
|
||||
@@ -12,7 +12,10 @@ export function getTimerByType(timerObject?: TimerTypeParams): number | null {
|
||||
switch (timerObject.timerType) {
|
||||
case TimerType.CountDown:
|
||||
case TimerType.TimeToEnd:
|
||||
return timerObject.current;
|
||||
if (timerObject.current === null) {
|
||||
return null;
|
||||
}
|
||||
return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current;
|
||||
case TimerType.CountUp:
|
||||
return Math.abs(timerObject.elapsed ?? 0);
|
||||
case TimerType.Clock:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
|
||||
import { millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils';
|
||||
import { MILLIS_PER_SECOND, millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||
@@ -150,7 +150,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor;
|
||||
if (!timerIsTimeOfDay && showProgress && showDanger) timerColor = viewSettings.dangerColor;
|
||||
|
||||
const stageTimer = getTimerByType(time);
|
||||
const stageTimer = getTimerByType(viewSettings.freezeEnd, time);
|
||||
let display = millisToString(stageTimer, { fallback: timerPlaceholder });
|
||||
if (stageTimer !== null) {
|
||||
if (hideTimerSeconds) {
|
||||
@@ -158,7 +158,8 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
}
|
||||
display = removeLeadingZero(display);
|
||||
// last unit rounds up in negative timers
|
||||
const isNegative = (stageTimer ?? 0 < 0) && !timerIsTimeOfDay && time.timerType !== TimerType.CountUp;
|
||||
const isNegative =
|
||||
(stageTimer ?? 0 < MILLIS_PER_SECOND) && !timerIsTimeOfDay && time.timerType !== TimerType.CountUp;
|
||||
if (isNegative && display === '0') {
|
||||
display = '-1';
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
TimerType,
|
||||
ViewSettings,
|
||||
} from 'ontime-types';
|
||||
import { millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils';
|
||||
import { MILLIS_PER_SECOND, millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
||||
@@ -59,6 +59,7 @@ interface TimerProps {
|
||||
|
||||
export default function Timer(props: TimerProps) {
|
||||
const { customFields, isMirrored, pres, eventNow, eventNext, time, viewSettings, external, settings } = props;
|
||||
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -127,7 +128,7 @@ export default function Timer(props: TimerProps) {
|
||||
if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor;
|
||||
if (!timerIsTimeOfDay && showProgress && showDanger) timerColor = viewSettings.dangerColor;
|
||||
|
||||
const stageTimer = getTimerByType(time);
|
||||
const stageTimer = getTimerByType(viewSettings.freezeEnd, time);
|
||||
let display = millisToString(stageTimer, { fallback: timerPlaceholder });
|
||||
if (stageTimer !== null) {
|
||||
if (hideTimerSeconds) {
|
||||
@@ -135,7 +136,8 @@ export default function Timer(props: TimerProps) {
|
||||
}
|
||||
display = removeLeadingZero(display);
|
||||
// last unit rounds up in negative timers
|
||||
const isNegative = (stageTimer ?? 0 < 0) && !timerIsTimeOfDay && time.timerType !== TimerType.CountUp;
|
||||
const isNegative =
|
||||
(stageTimer ?? 0 < -MILLIS_PER_SECOND) && !timerIsTimeOfDay && time.timerType !== TimerType.CountUp;
|
||||
if (isNegative && display === '0') {
|
||||
display = '-1';
|
||||
}
|
||||
@@ -215,7 +217,7 @@ export default function Timer(props: TimerProps) {
|
||||
{!userOptions.hideCards && (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{eventNow && (
|
||||
{eventNow?.title && (
|
||||
<motion.div
|
||||
className='event now'
|
||||
key='now'
|
||||
@@ -230,7 +232,7 @@ export default function Timer(props: TimerProps) {
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{eventNext && (
|
||||
{eventNext?.title && (
|
||||
<motion.div
|
||||
className='event next'
|
||||
key='next'
|
||||
|
||||
@@ -42,7 +42,7 @@ Sentry.init({
|
||||
tracesSampleRate: 0.3,
|
||||
release: ONTIME_VERSION,
|
||||
enabled: import.meta.env.PROD,
|
||||
ignoreErrors: [...sentryRecommendedIgnore, 'Unable to preload CSS', 'Failed to fetch dynamically imported module'],
|
||||
ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i],
|
||||
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
import { ontimeTooltip } from './ontimeTooltip';
|
||||
|
||||
const theme = extendTheme({
|
||||
initialColorMode: 'dark',
|
||||
useSystemColorMode: false,
|
||||
components: {
|
||||
Alert: {
|
||||
variants: {
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
"build:electron": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --outfile=dist/index.cjs",
|
||||
"build:local": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --outfile=dist/index.cjs",
|
||||
"build:docker": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --legal-comments=external --outfile=dist/docker.cjs",
|
||||
"build:localdocker": "NODE_ENV=local pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --legal-comments=external --outfile=dist/docker.cjs",
|
||||
"build:localdocker": "cross-env NODE_ENV=local pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --legal-comments=external --outfile=dist/docker.cjs",
|
||||
"build:debug": "pnpm prebuild && esbuild src/app.ts --platform=node --format=cjs --bundle --legal-comments=external --outfile=dist/index.cjs",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint-staged": "eslint",
|
||||
|
||||
@@ -8,20 +8,14 @@ import {
|
||||
} from 'ontime-types';
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||
import { resolveDbPath, resolveProjectsDirectory } from '../../setup/index.js';
|
||||
|
||||
import * as projectService from '../../services/project-service/ProjectService.js';
|
||||
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
|
||||
import { setRundown } from '../../services/rundown-service/RundownService.js';
|
||||
import { ensureJsonExtension } from '../../utils/fileManagement.js';
|
||||
import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js';
|
||||
import { appStateService } from '../../services/app-state-service/AppStateService.js';
|
||||
import { handleMaybeExcel } from '../../utils/parser.js';
|
||||
|
||||
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
|
||||
// all fields are optional in validation
|
||||
@@ -31,23 +25,10 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
|
||||
}
|
||||
|
||||
try {
|
||||
const patchDb: Partial<DatabaseModel> = {
|
||||
project: req.body?.project,
|
||||
settings: req.body?.settings,
|
||||
viewSettings: req.body?.viewSettings,
|
||||
osc: req.body?.osc,
|
||||
urlPresets: req.body?.urlPresets,
|
||||
customFields: req.body?.customFields,
|
||||
};
|
||||
const { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http } = req.body;
|
||||
const patchDb: DatabaseModel = { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http };
|
||||
|
||||
const maybeRundown = req.body?.rundown;
|
||||
await DataProvider.mergeIntoData(patchDb);
|
||||
if (maybeRundown !== undefined) {
|
||||
// it is likely cheaper to invalidate cache than to calculate diff
|
||||
runtimeService.stop();
|
||||
await setRundown(maybeRundown);
|
||||
}
|
||||
const newData = DataProvider.getData();
|
||||
const newData = await projectService.applyDataModel(patchDb);
|
||||
res.status(200).send(newData);
|
||||
} catch (error) {
|
||||
res.status(400).send({ message: String(error) });
|
||||
@@ -93,9 +74,7 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
|
||||
}
|
||||
|
||||
export async function projectDownload(_req: Request, res: Response) {
|
||||
const { title } = DataProvider.getProjectData();
|
||||
const fileTitle = title || 'ontime data';
|
||||
|
||||
const fileTitle = projectService.getProjectTitle();
|
||||
res.download(resolveDbPath, `${fileTitle}.json`, (err) => {
|
||||
if (err) {
|
||||
res.status(500).send({
|
||||
@@ -116,9 +95,14 @@ export async function postProjectFile(req: Request, res: Response<MessageRespons
|
||||
|
||||
try {
|
||||
const options = req.query;
|
||||
const filePath = req.file.path;
|
||||
await projectService.applyProjectFile(filePath, options);
|
||||
res.status(201).send({ message: 'ok' });
|
||||
const { filename, path } = req.file;
|
||||
|
||||
await projectService.handleUploadedFile(path, filename);
|
||||
await projectService.applyProjectFile(filename, options);
|
||||
|
||||
res.status(201).send({
|
||||
message: `Loaded project ${filename}`,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(400).send({ message: `Failed parsing ${error}` });
|
||||
}
|
||||
@@ -141,15 +125,15 @@ export async function listProjects(_req: Request, res: Response<ProjectFileListR
|
||||
*/
|
||||
export async function loadProject(req: Request, res: Response<MessageResponse | ErrorResponse>) {
|
||||
try {
|
||||
const filename = req.body.filename;
|
||||
const filePath = join(resolveProjectsDirectory, filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const name = req.body.filename;
|
||||
if (!projectService.doesProjectExist(name)) {
|
||||
return res.status(404).send({ message: 'File not found' });
|
||||
}
|
||||
await projectService.applyProjectFile(filePath);
|
||||
|
||||
await projectService.applyProjectFile(name);
|
||||
|
||||
res.status(201).send({
|
||||
message: `Loaded project ${filename}`,
|
||||
message: `Loaded project ${name}`,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).send({ message: String(error) });
|
||||
@@ -259,27 +243,3 @@ export async function getInfo(_req: Request, res: Response<GetInfo>) {
|
||||
const info = await projectService.getInfo();
|
||||
res.status(200).send(info);
|
||||
}
|
||||
|
||||
/**
|
||||
* uploads and parses an excel spreadsheet
|
||||
* @returns parsed result
|
||||
*/
|
||||
export async function previewSpreadsheet(req: Request, res: Response) {
|
||||
if (!req.file) {
|
||||
res.status(400).send({ message: 'File not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = req.file.path;
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
const options = JSON.parse(req.body.options);
|
||||
const { data } = handleMaybeExcel(filePath, options);
|
||||
res.status(200).send(data);
|
||||
} catch (error) {
|
||||
res.status(500).send({ message: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request } from 'express';
|
||||
import multer, { FileFilterCallback } from 'multer';
|
||||
|
||||
import { EXCEL_MIME, JSON_MIME } from '../../utils/parser.js';
|
||||
import { JSON_MIME } from '../../utils/parser.js';
|
||||
import { storage } from '../../utils/upload.js';
|
||||
|
||||
const filterProjectFile = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
|
||||
@@ -12,21 +12,8 @@ const filterProjectFile = (_req: Request, file: Express.Multer.File, cb: FileFil
|
||||
}
|
||||
};
|
||||
|
||||
const filterSpreadsheet = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
|
||||
if (file.mimetype.includes(EXCEL_MIME)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(null, false);
|
||||
}
|
||||
};
|
||||
|
||||
// Build multer uploader for a single file
|
||||
export const uploadProjectFile = multer({
|
||||
storage,
|
||||
fileFilter: filterProjectFile,
|
||||
}).single('project');
|
||||
|
||||
export const uploadSpreadsheet = multer({
|
||||
storage,
|
||||
fileFilter: filterSpreadsheet,
|
||||
}).single('spreadsheet');
|
||||
|
||||
@@ -2,18 +2,17 @@ import express from 'express';
|
||||
|
||||
import {
|
||||
createProjectFile,
|
||||
projectDownload,
|
||||
deleteProjectFile,
|
||||
duplicateProjectFile,
|
||||
getInfo,
|
||||
listProjects,
|
||||
patchPartialProjectFile,
|
||||
previewSpreadsheet,
|
||||
loadProject,
|
||||
duplicateProjectFile,
|
||||
renameProjectFile,
|
||||
patchPartialProjectFile,
|
||||
postProjectFile,
|
||||
projectDownload,
|
||||
renameProjectFile,
|
||||
} from './db.controller.js';
|
||||
import { uploadProjectFile, uploadSpreadsheet } from './db.middleware.js';
|
||||
import { uploadProjectFile } from './db.middleware.js';
|
||||
import {
|
||||
projectSanitiser,
|
||||
sanitizeProjectFilename,
|
||||
@@ -39,6 +38,3 @@ router.put('/:filename/rename', validateProjectRename, sanitizeProjectFilename,
|
||||
router.delete('/:filename', sanitizeProjectFilename, deleteProjectFile);
|
||||
|
||||
router.get('/info', getInfo);
|
||||
|
||||
// TODO: validate import map
|
||||
router.post('/spreadsheet/preview', uploadSpreadsheet, previewSpreadsheet);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* This module encapsulates logic related to
|
||||
* Google Sheets
|
||||
*/
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { generateRundownPreview, listWorksheets, saveExcelFile } from './excel.service.js';
|
||||
|
||||
export async function postExcel(req: Request, res: Response) {
|
||||
try {
|
||||
const filePath = req.file.path;
|
||||
await saveExcelFile(filePath);
|
||||
res.status(200).send();
|
||||
} catch (error) {
|
||||
res.status(500).send({ message: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorksheets(req: Request, res: Response) {
|
||||
try {
|
||||
const names = listWorksheets();
|
||||
res.status(200).send(names);
|
||||
} catch (error) {
|
||||
res.status(500).send({ message: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* parses an Excel spreadsheet
|
||||
* @returns parsed result
|
||||
*/
|
||||
export async function previewExcel(req: Request, res: Response) {
|
||||
try {
|
||||
const { options } = req.body;
|
||||
const data = generateRundownPreview(options);
|
||||
res.status(200).send(data);
|
||||
} catch (error) {
|
||||
res.status(500).send({ message: String(error) });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Request } from 'express';
|
||||
import multer, { FileFilterCallback } from 'multer';
|
||||
|
||||
import { EXCEL_MIME } from '../../utils/parser.js';
|
||||
import { storage } from '../../utils/upload.js';
|
||||
|
||||
const filterExcel = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
|
||||
if (file.mimetype.includes(EXCEL_MIME)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(null, false);
|
||||
}
|
||||
};
|
||||
export const uploadExcel = multer({
|
||||
storage,
|
||||
fileFilter: filterExcel,
|
||||
}).single('excel');
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* This is a feature specific router for integration with Excel
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { uploadExcel } from './excel.middleware.js';
|
||||
import { getWorksheets, postExcel, previewExcel } from './excel.controller.js';
|
||||
import { validateFileExists, validateImportMapOptions } from './excel.validation.js';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
router.post('/upload', uploadExcel, validateFileExists, postExcel);
|
||||
router.get('/worksheets', getWorksheets);
|
||||
router.post('/preview', validateImportMapOptions, previewExcel);
|
||||
|
||||
// TODO: validate import map
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* This module encapsulates logic related to
|
||||
* Google Sheets
|
||||
*/
|
||||
|
||||
import { extname } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
import xlsx from 'node-xlsx';
|
||||
import { parseExcel } from '../../utils/parser.js';
|
||||
import { parseCustomFields, parseRundown } from '../../utils/parserFunctions.js';
|
||||
import { deleteFile } from '../../utils/parserUtils.js';
|
||||
|
||||
let excelData: { name: string; data: unknown[][] }[] = [];
|
||||
|
||||
export async function saveExcelFile(filePath: string) {
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error('Upload of excel file failed');
|
||||
}
|
||||
if (extname(filePath) != '.xlsx') {
|
||||
throw new Error('Wrong file format');
|
||||
}
|
||||
excelData = xlsx.parse(filePath, { cellDates: true });
|
||||
|
||||
await deleteFile(filePath);
|
||||
}
|
||||
|
||||
export function listWorksheets() {
|
||||
return excelData.map((value) => value.name);
|
||||
}
|
||||
|
||||
export function generateRundownPreview(options: ImportMap) {
|
||||
const data = excelData.find(({ name }) => name.toLowerCase() === options.worksheet.toLowerCase())?.data;
|
||||
|
||||
if (!data) {
|
||||
throw new Error(`Could not find data to import, maybe the worksheet name is incorrect: ${options.worksheet}`);
|
||||
}
|
||||
|
||||
const dataFromExcel = parseExcel(data, options);
|
||||
|
||||
// we run the parsed data through an extra step to ensure the objects shape
|
||||
const result = { rundown: [], customFields: {} };
|
||||
result.rundown = parseRundown(dataFromExcel);
|
||||
if (result.rundown.length < 1) {
|
||||
throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`);
|
||||
}
|
||||
result.customFields = parseCustomFields(dataFromExcel);
|
||||
|
||||
//clear the data
|
||||
excelData = [];
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { isImportMap } from 'ontime-utils';
|
||||
|
||||
import { body, validationResult } from 'express-validator';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
export const validateFileExists = [
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.file) {
|
||||
return res.status(422).json({ errors: 'File not found' });
|
||||
}
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
export const validateImportMapOptions = [
|
||||
body('options')
|
||||
.exists()
|
||||
.isObject()
|
||||
.custom((content) => {
|
||||
return isImportMap(content);
|
||||
}),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||
next();
|
||||
},
|
||||
];
|
||||
@@ -9,6 +9,7 @@ import { router as projectRouter } from './project/project.router.js';
|
||||
import { router as rundownRouter } from './rundown/rundown.router.js';
|
||||
import { router as settingsRouter } from './settings/settings.router.js';
|
||||
import { router as sheetsRouter } from './sheets/sheets.router.js';
|
||||
import { router as excelRouter } from './excel/excel.router.js';
|
||||
import { router as viewSettingsRouter } from './view-settings/viewSettings.router.js';
|
||||
|
||||
export const appRouter = express.Router();
|
||||
@@ -21,5 +22,6 @@ appRouter.use('/project', projectRouter);
|
||||
appRouter.use('/rundown', rundownRouter);
|
||||
appRouter.use('/settings', settingsRouter);
|
||||
appRouter.use('/sheets', sheetsRouter);
|
||||
appRouter.use('/excel', excelRouter);
|
||||
appRouter.use('/url-presets', urlPresetsRouter);
|
||||
appRouter.use('/view-settings', viewSettingsRouter);
|
||||
|
||||
@@ -6,10 +6,20 @@ import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||
import { extractPin } from '../../services/project-service/ProjectService.js';
|
||||
import { isDocker } from '../../setup/index.js';
|
||||
import { obfuscate } from 'ontime-utils';
|
||||
|
||||
export async function getSettings(_req: Request, res: Response<Settings>) {
|
||||
const settings = DataProvider.getSettings();
|
||||
res.status(200).send(settings);
|
||||
const obfuscatedSettings = { ...settings };
|
||||
if (settings.editorKey) {
|
||||
obfuscatedSettings.editorKey = obfuscate(settings.editorKey);
|
||||
}
|
||||
|
||||
if (settings.operatorKey) {
|
||||
obfuscatedSettings.editorKey = obfuscate(settings.editorKey);
|
||||
}
|
||||
|
||||
res.status(200).send(obfuscatedSettings);
|
||||
}
|
||||
|
||||
export async function postSettings(req: Request, res: Response<Settings | ErrorResponse>) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
hasAuth,
|
||||
download,
|
||||
upload,
|
||||
getWorksheetOptions,
|
||||
} from '../../services/sheet-service/SheetService.js';
|
||||
|
||||
export async function requestConnection(req: Request, res: Response) {
|
||||
@@ -56,6 +57,16 @@ export async function revokeAuthentication(_req: Request, res: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorksheetNamesFromSheet(req: Request, res: Response) {
|
||||
try {
|
||||
const { sheetId } = req.params;
|
||||
const { worksheetOptions } = await getWorksheetOptions(sheetId);
|
||||
res.status(200).send(worksheetOptions);
|
||||
} catch (error) {
|
||||
res.status(500).send({ message: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
export async function readFromSheet(req: Request, res: Response) {
|
||||
try {
|
||||
const { sheetId } = req.params;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import express from 'express';
|
||||
|
||||
import {
|
||||
getWorksheetNamesFromSheet,
|
||||
readFromSheet,
|
||||
requestConnection,
|
||||
revokeAuthentication,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
writeToSheet,
|
||||
} from './sheets.controller.js';
|
||||
import { uploadClientSecret } from './sheets.middleware.js';
|
||||
import { validateRequestConnection, validateSheetOptions } from './sheets.validation.js';
|
||||
import { validateRequestConnection, validateSheetId, validateSheetOptions } from './sheets.validation.js';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
@@ -21,5 +22,7 @@ router.post('/:sheetId/connect', uploadClientSecret, validateRequestConnection,
|
||||
|
||||
router.post('/revoke', revokeAuthentication);
|
||||
|
||||
router.post('/:sheetId/worksheets', validateSheetId, getWorksheetNamesFromSheet);
|
||||
|
||||
router.post('/:sheetId/read', validateSheetOptions, readFromSheet);
|
||||
router.post('/:sheetId/write', validateSheetOptions, writeToSheet);
|
||||
|
||||
@@ -20,6 +20,16 @@ export const validateRequestConnection = [
|
||||
},
|
||||
];
|
||||
|
||||
export const validateSheetId = [
|
||||
param('sheetId').exists().isString(),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
export const validateSheetOptions = [
|
||||
param('sheetId').exists().isString(),
|
||||
body('options')
|
||||
|
||||
@@ -17,11 +17,12 @@ export async function postViewSettings(req: Request, res: Response<ViewSettings
|
||||
|
||||
try {
|
||||
const newData = {
|
||||
overrideStyles: req.body.overrideStyles,
|
||||
endMessage: req.body?.endMessage || '',
|
||||
normalColor: req.body.normalColor,
|
||||
warningColor: req.body.warningColor,
|
||||
dangerColor: req.body.dangerColor,
|
||||
endMessage: req.body?.endMessage ?? '',
|
||||
freezeEnd: req.body.freezeEnd,
|
||||
normalColor: req.body.normalColor,
|
||||
overrideStyles: req.body.overrideStyles,
|
||||
warningColor: req.body.warningColor,
|
||||
};
|
||||
await DataProvider.setViewSettings(newData);
|
||||
res.status(200).send(newData);
|
||||
|
||||
@@ -40,9 +40,8 @@ import { runtimeService } from './services/runtime-service/RuntimeService.js';
|
||||
import { restoreService } from './services/RestoreService.js';
|
||||
import { messageService } from './services/message-service/MessageService.js';
|
||||
import { populateDemo } from './setup/loadDemo.js';
|
||||
import { getState, updateRundownData } from './stores/runtimeState.js';
|
||||
import { getState } from './stores/runtimeState.js';
|
||||
import { initRundown } from './services/rundown-service/RundownService.js';
|
||||
import { getPlayableEvents } from './services/rundown-service/rundownUtils.js';
|
||||
import { generateCrashReport } from './utils/generateCrashReport.js';
|
||||
|
||||
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||
@@ -183,9 +182,6 @@ export const startServer = async () => {
|
||||
const persistedCustomFields = DataProvider.getCustomFields();
|
||||
initRundown(persistedRundown, persistedCustomFields);
|
||||
|
||||
// TODO: do this on the init of the runtime service
|
||||
updateRundownData(getPlayableEvents());
|
||||
|
||||
// load restore point if it exists
|
||||
const maybeRestorePoint = await restoreService.load();
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export class DataProvider {
|
||||
|
||||
static async setProjectData(newData: Partial<ProjectData>) {
|
||||
data.project = { ...data.project, ...newData };
|
||||
await this.persist();
|
||||
this.persist();
|
||||
return data.project;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export class DataProvider {
|
||||
|
||||
static async setCustomFields(newData: CustomFields): Promise<CustomFields> {
|
||||
data.customFields = { ...newData };
|
||||
await this.persist();
|
||||
this.persist();
|
||||
return data.customFields;
|
||||
}
|
||||
|
||||
@@ -45,16 +45,18 @@ export class DataProvider {
|
||||
|
||||
static async setRundown(newData: OntimeRundown) {
|
||||
data.rundown = [...newData];
|
||||
await this.persist();
|
||||
this.persist();
|
||||
return data.rundown;
|
||||
}
|
||||
|
||||
static getSettings(): Settings {
|
||||
static getSettings(): Readonly<Settings> {
|
||||
return data.settings;
|
||||
}
|
||||
|
||||
static async setSettings(newData: Settings) {
|
||||
data.settings = { ...newData };
|
||||
await this.persist();
|
||||
this.persist();
|
||||
return data.settings;
|
||||
}
|
||||
|
||||
static getOsc(): OSCSettings {
|
||||
@@ -71,7 +73,8 @@ export class DataProvider {
|
||||
|
||||
static async setUrlPresets(newData: URLPreset[]) {
|
||||
data.urlPresets = newData;
|
||||
await this.persist();
|
||||
this.persist();
|
||||
return data.urlPresets;
|
||||
}
|
||||
|
||||
static getViewSettings() {
|
||||
@@ -80,18 +83,19 @@ export class DataProvider {
|
||||
|
||||
static async setViewSettings(newData: ViewSettings) {
|
||||
data.viewSettings = { ...newData };
|
||||
await this.persist();
|
||||
this.persist();
|
||||
return data.viewSettings;
|
||||
}
|
||||
|
||||
static async setOsc(newData: OSCSettings): Promise<OSCSettings> {
|
||||
data.osc = { ...newData };
|
||||
await this.persist();
|
||||
this.persist();
|
||||
return data.osc;
|
||||
}
|
||||
|
||||
static async setHttp(newData: HttpSettings): Promise<HttpSettings> {
|
||||
data.http = { ...newData };
|
||||
await this.persist();
|
||||
this.persist();
|
||||
return data.http;
|
||||
}
|
||||
|
||||
@@ -116,6 +120,9 @@ export class DataProvider {
|
||||
data.urlPresets = mergedData.urlPresets;
|
||||
data.customFields = mergedData.customFields;
|
||||
data.rundown = mergedData.rundown;
|
||||
await this.persist();
|
||||
|
||||
this.persist();
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ describe('safeMerge', () => {
|
||||
},
|
||||
viewSettings: {
|
||||
overrideStyles: false,
|
||||
freezeEnd: false,
|
||||
endMessage: 'existing endMessage',
|
||||
normalColor: '#ffffffcc',
|
||||
warningColor: '#FFAB33',
|
||||
|
||||
@@ -2,4 +2,5 @@ export const timerConfig = {
|
||||
skipLimit: 1000, // threshold of skip for recalculating
|
||||
updateRate: 32, // how often do we update the timer
|
||||
notificationRate: 1000, // how often do we notify clients and integrations
|
||||
triggerAhead: 16, // how far ahead do we trigger the end event
|
||||
};
|
||||
|
||||
Vendored
+30
-14
@@ -12,6 +12,23 @@ const leftPad = (number) => {
|
||||
return Math.floor(number).toString().padStart(2, '0');
|
||||
};
|
||||
|
||||
const formatTimer = (number) => {
|
||||
const millis = Math.abs(number);
|
||||
const isNegative = number < 0;
|
||||
return `${isNegative ? '-' : ''}${leftPad(millis / mth)}:${leftPad((millis % mth) / mtm)}:${leftPad(
|
||||
(millis % mtm) / mts,
|
||||
)}`;
|
||||
};
|
||||
|
||||
function updateTimerElement(playback, timerValue) {
|
||||
const timerElement = document.getElementById('timer');
|
||||
if (playback === 'stop') {
|
||||
timerElement.innerText = '--:--:--';
|
||||
} else {
|
||||
timerElement.innerText = formatTimer(timerValue);
|
||||
}
|
||||
}
|
||||
|
||||
let reconnectTimeout;
|
||||
const reconnectInterval = 1000;
|
||||
let reconnectAttempts = 0;
|
||||
@@ -22,7 +39,7 @@ const connectSocket = () => {
|
||||
websocket.onopen = () => {
|
||||
clearTimeout(reconnectTimeout);
|
||||
reconnectAttempts = 0;
|
||||
console.info('WebSocket connected');
|
||||
console.warn('WebSocket connected');
|
||||
};
|
||||
|
||||
websocket.onclose = () => {
|
||||
@@ -46,19 +63,18 @@ const connectSocket = () => {
|
||||
const { type, payload } = data;
|
||||
|
||||
// we only need to read message type of ontime
|
||||
if (type === 'ontime') {
|
||||
// destructure known data from ontime
|
||||
// see https://docs.getontime.no/api/osc-and-ws/
|
||||
const { timer, playback } = payload;
|
||||
const timerElement = document.getElementById('timer');
|
||||
if (playback == 'stop') {
|
||||
timerElement.innerText = '--:--:--';
|
||||
} else {
|
||||
const millis = Math.abs(timer.current);
|
||||
const isNegative = timer.current < 0;
|
||||
timerElement.innerText = `${isNegative ? '-' : ''}${leftPad(millis / mth)}:${leftPad(
|
||||
(millis % mth) / mtm,
|
||||
)}:${leftPad((millis % mtm) / mts)}`;
|
||||
switch (type) {
|
||||
case 'ontime': {
|
||||
// destructure known data from ontime
|
||||
// see https://docs.getontime.no/api/osc-and-ws/
|
||||
const { timer, playback } = payload;
|
||||
updateTimerElement(playback, timer);
|
||||
break;
|
||||
}
|
||||
case 'ontime-timer': {
|
||||
const { current, playback } = payload;
|
||||
updateTimerElement(playback, current);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ export const dbModel: DatabaseModel = {
|
||||
normalColor: '#ffffffcc',
|
||||
warningColor: '#FFAB33',
|
||||
dangerColor: '#ED3333',
|
||||
freezeEnd: false,
|
||||
endMessage: '',
|
||||
},
|
||||
urlPresets: [],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { MaybeNumber, MaybeString, Playback } from 'ontime-types';
|
||||
|
||||
import { JSONFile } from 'lowdb/node';
|
||||
import { resolveRestoreFile } from '../setup/index.js';
|
||||
import { deepEqual } from 'fast-equals';
|
||||
|
||||
export type RestorePoint = {
|
||||
playback: Playback;
|
||||
@@ -62,13 +63,13 @@ export function isRestorePoint(obj: unknown): obj is RestorePoint {
|
||||
export class RestoreService {
|
||||
private readonly filePath: MaybeString;
|
||||
private readonly file: JSONFile<RestorePoint | null>;
|
||||
private lastStore: MaybeString;
|
||||
private failedCreateAttempts: number;
|
||||
private savedState: RestorePoint;
|
||||
|
||||
constructor(filePath: string) {
|
||||
this.filePath = filePath;
|
||||
|
||||
this.lastStore = null;
|
||||
this.savedState = null;
|
||||
this.file = new JSONFile(this.filePath);
|
||||
this.failedCreateAttempts = 0;
|
||||
}
|
||||
@@ -100,15 +101,16 @@ export class RestoreService {
|
||||
return;
|
||||
}
|
||||
|
||||
const stringifiedStore = JSON.stringify(newState);
|
||||
if (stringifiedStore !== this.lastStore) {
|
||||
try {
|
||||
await this.write(newState);
|
||||
this.lastStore = stringifiedStore;
|
||||
this.failedCreateAttempts = 0;
|
||||
} catch (_error) {
|
||||
this.failedCreateAttempts += 1;
|
||||
}
|
||||
if (deepEqual(newState, this.savedState)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.write(newState);
|
||||
this.savedState = { ...newState };
|
||||
this.failedCreateAttempts = 0;
|
||||
} catch (_error) {
|
||||
this.failedCreateAttempts += 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export class TimerService {
|
||||
this.onUpdateCallback = timerConfig.onUpdateCallback;
|
||||
this._interval = setInterval(() => {
|
||||
this.update();
|
||||
}, TimerService._updateInterval);
|
||||
}, TimerService._refreshInterval);
|
||||
}
|
||||
|
||||
@broadcastResult
|
||||
@@ -58,7 +58,8 @@ export class TimerService {
|
||||
}
|
||||
|
||||
const state = runtimeState.getState();
|
||||
this.endCallback = setTimeout(this.update, state.timer.expectedFinish);
|
||||
const endTime = state.timer.current - 10;
|
||||
this.endCallback = setTimeout(() => this.update(), endTime);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -97,7 +98,7 @@ export class TimerService {
|
||||
// renew end callback
|
||||
clearTimeout(this.endCallback);
|
||||
const state = runtimeState.getState();
|
||||
this.endCallback = setTimeout(this.update, state.timer.expectedFinish);
|
||||
this.endCallback = setTimeout(() => this.update(), state.timer.expectedFinish);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -107,7 +108,6 @@ export class TimerService {
|
||||
@broadcastResult
|
||||
update() {
|
||||
const updateResult = runtimeState.update();
|
||||
|
||||
// pass the result to the parent
|
||||
this.onUpdateCallback(updateResult);
|
||||
}
|
||||
@@ -139,10 +139,13 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
||||
// to apply custom logic for different datasets
|
||||
|
||||
// some of the data, we only update at intervals
|
||||
const isTimeToUpdate = state.clock - TimerService.previousUpdate >= TimerService._updateInterval;
|
||||
const isTimeToUpdate =
|
||||
state.clock < TimerService.previousUpdate ||
|
||||
state.clock - TimerService.previousUpdate >= TimerService._updateInterval;
|
||||
|
||||
// some changes need an immediate update
|
||||
const hasNewLoaded = state.eventNow?.id !== TimerService.previousState?.eventNow?.id;
|
||||
|
||||
const hasSkippedBack = state.clock < TimerService.previousUpdate;
|
||||
const justStarted = !TimerService.previousState?.timer;
|
||||
const hasChangedPlayback = TimerService.previousState.timer?.playback !== state.timer.playback;
|
||||
@@ -175,7 +178,27 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
||||
|
||||
// Helper function to update an event if it has changed
|
||||
function updateEventIfChanged(eventKey: keyof RuntimeStore, state: RuntimeState) {
|
||||
const previous = TimerService.previousState?.[eventKey];
|
||||
const now = state[eventKey];
|
||||
|
||||
// if there was nothing, and there is nothing, noop
|
||||
if (!previous?.id && !now?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if load status changed, save new
|
||||
if (previous?.id !== now?.id) {
|
||||
storeKey(eventKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// maybe the event itself has changed
|
||||
if (!deepEqual(TimerService.previousState?.[eventKey], state[eventKey])) {
|
||||
storeKey(eventKey);
|
||||
return;
|
||||
}
|
||||
|
||||
function storeKey(eventKey: keyof RuntimeStore) {
|
||||
eventStore.set(eventKey, state[eventKey]);
|
||||
TimerService.previousState[eventKey] = { ...state[eventKey] };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
import { MILLIS_PER_HOUR, dayInMs, millisToString } from 'ontime-utils';
|
||||
import { EndAction, OntimeEvent, Playback, TimeStrategy, TimerType } from 'ontime-types';
|
||||
|
||||
import {
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getExpectedFinish,
|
||||
getRollTimers,
|
||||
getRuntimeOffset,
|
||||
getTotalDuration,
|
||||
normaliseEndTime,
|
||||
skippedOutOfEvent,
|
||||
updateRoll,
|
||||
@@ -422,35 +423,6 @@ describe('getCurrent()', () => {
|
||||
expect(current).toBe(77);
|
||||
});
|
||||
|
||||
it('handles events that start the day after', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeStart: 60000, // 00:01:00
|
||||
timeEnd: 600000, // 00:10:00
|
||||
timerType: TimerType.TimeToEnd,
|
||||
},
|
||||
clock: 79500000, // 22:05:00
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration: Infinity, // not relevant,
|
||||
startedAt: 79200000, // 22:00:00
|
||||
finishedAt: null,
|
||||
},
|
||||
runtime: {
|
||||
plannedStart: 60000, // 00:01:00
|
||||
plannedEnd: 79200000, // 22:00:00
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const current = getCurrent(state);
|
||||
// day - clock + start time
|
||||
const expectedCurrent = dayInMs - 79500000 + 60000;
|
||||
expect(current).toBe(expectedCurrent);
|
||||
});
|
||||
|
||||
it('handles events that finish the day after', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
@@ -477,6 +449,34 @@ describe('getCurrent()', () => {
|
||||
const current = getCurrent(state);
|
||||
expect(current).toBe(dayInMs - 79500000 + 600000);
|
||||
});
|
||||
|
||||
it('handles events that were started late', () => {
|
||||
const state = {
|
||||
clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end
|
||||
eventNow: {
|
||||
timeStart: 77400000, // 21:30:00
|
||||
timeEnd: 81000000, // 22:30:00
|
||||
duration: 3600000, // 01:00:00
|
||||
timerType: TimerType.TimeToEnd,
|
||||
},
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration: Infinity, // not relevant,
|
||||
startedAt: 79200000, // 22:00:00
|
||||
finishedAt: null,
|
||||
},
|
||||
runtime: {
|
||||
actualStart: 82000000, // 22:46:40 <--- started now
|
||||
plannedEnd: 81000000, // 22:30:00
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const current = getCurrent(state);
|
||||
expect(current).toBe(81000000 - 82000000); // <-- planned end - now
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1219,7 +1219,7 @@ describe('updateRoll()', () => {
|
||||
clock: 11,
|
||||
timer: {
|
||||
current: 10,
|
||||
expectedFinish: 15,
|
||||
expectedFinish: 100,
|
||||
secondaryTimer: null,
|
||||
startedAt: 1,
|
||||
},
|
||||
@@ -1229,7 +1229,7 @@ describe('updateRoll()', () => {
|
||||
} as RuntimeState;
|
||||
|
||||
const expected = {
|
||||
updatedTimer: 15 - 11,
|
||||
updatedTimer: 100 - 11,
|
||||
updatedSecondaryTimer: null, // usually clock - expectedFinish
|
||||
doRollLoad: false,
|
||||
isFinished: false,
|
||||
@@ -1680,4 +1680,92 @@ describe('getRuntimeOffset()', () => {
|
||||
const offset = getRuntimeOffset(state);
|
||||
expect(offset).toBe(-400000);
|
||||
});
|
||||
|
||||
it('handles time-to-end started after the end time', () => {
|
||||
const state = {
|
||||
clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end
|
||||
eventNow: {
|
||||
id: 'd6a2ce',
|
||||
type: 'event',
|
||||
title: '',
|
||||
timeStart: 77400000, // 21:30:00
|
||||
timeEnd: 81000000, // 22:30:00
|
||||
duration: 3600000, // 01:00:00
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
linkStart: null,
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.TimeToEnd,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
note: '',
|
||||
colour: '',
|
||||
cue: '1',
|
||||
revision: 0,
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
custom: {},
|
||||
delay: 0,
|
||||
},
|
||||
runtime: {
|
||||
selectedEventIndex: 0,
|
||||
numEvents: 1,
|
||||
offset: null,
|
||||
plannedStart: 77400000, // 21:30:00
|
||||
plannedEnd: 81000000, // 22:30:00
|
||||
actualStart: 82000000, // 22:46:40 <--- started now
|
||||
expectedEnd: 82000000 + 3600000, // <--- now + duration
|
||||
},
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
current: 0,
|
||||
duration: 3600000,
|
||||
elapsed: 0,
|
||||
expectedFinish: 82000000 + 3600000, // <--- now + duration
|
||||
finishedAt: null,
|
||||
playback: Playback.Play,
|
||||
secondaryTimer: null,
|
||||
startedAt: 82000000, // <--- started now
|
||||
},
|
||||
_timer: { pausedAt: null, secondaryTarget: null },
|
||||
} as RuntimeState;
|
||||
|
||||
const updateCurrent = getCurrent(state);
|
||||
state.timer.current = updateCurrent;
|
||||
const offset = getRuntimeOffset(state);
|
||||
expect(offset).toBe(81000000 - 82000000); // <-- planned end - now
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTotalDuration()', () => {
|
||||
it('calculates the duration of events in a single day', () => {
|
||||
const start = MILLIS_PER_HOUR * 9;
|
||||
const end = MILLIS_PER_HOUR * 17;
|
||||
const daySpan = 0;
|
||||
const duration = getTotalDuration(start, end, daySpan);
|
||||
expect(duration).toBe(MILLIS_PER_HOUR * (17 - 9));
|
||||
});
|
||||
|
||||
it('calculates the duration of events across days', () => {
|
||||
const start = MILLIS_PER_HOUR * 9;
|
||||
const end = MILLIS_PER_HOUR * 17;
|
||||
const daySpan = 1;
|
||||
const duration = getTotalDuration(start, end, daySpan);
|
||||
expect(duration).toBe(MILLIS_PER_HOUR * (17 - 9) + dayInMs);
|
||||
});
|
||||
|
||||
it('calculates the duration of events across days (2)', () => {
|
||||
const start = new Date(0).setHours(12);
|
||||
const end = new Date(0).setHours(8);
|
||||
const daySpan = 1;
|
||||
const duration = getTotalDuration(start, end, daySpan);
|
||||
expect(millisToString(duration)).toBe('20:00:00');
|
||||
});
|
||||
|
||||
it('calculates the duration of events across days (3)', () => {
|
||||
const start = new Date(0).setHours(9);
|
||||
const end = new Date(0).setHours(23);
|
||||
const daySpan = 2;
|
||||
const duration = getTotalDuration(start, end, daySpan);
|
||||
expect(millisToString(duration)).toBe('62:00:00');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,18 +45,13 @@ export class HttpIntegration implements IIntegration<HttpSubscription> {
|
||||
}
|
||||
|
||||
const parsedMessage = parseTemplateNested(message, state || {});
|
||||
try {
|
||||
const parsedUrl = new URL(parsedMessage);
|
||||
this.emit(parsedUrl);
|
||||
} catch (error) {
|
||||
logger.error(LogOrigin.Tx, `HTTP Integration: ${error}`);
|
||||
}
|
||||
this.emit(parsedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
async emit(path: URL) {
|
||||
await got.get(path, {
|
||||
retry: { limit: 0 },
|
||||
emit(path: string) {
|
||||
got.get(path, { retry: { limit: 0 } }).catch((err) => {
|
||||
logger.error(LogOrigin.Tx, `HTTP Integration: ${err.code}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import { DatabaseModel, GetInfo, ProjectData, ProjectFile, ProjectFileListRespon
|
||||
|
||||
import { copyFile, rename, stat, writeFile } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { basename, join } from 'path';
|
||||
import { join } from 'path';
|
||||
|
||||
import { notifyChanges, setRundown } from '../rundown-service/RundownService.js';
|
||||
import { initRundown } from '../rundown-service/RundownService.js';
|
||||
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { runtimeService } from '../runtime-service/RuntimeService.js';
|
||||
import { getNetworkInterfaces } from '../../utils/networkInterfaces.js';
|
||||
@@ -33,22 +33,31 @@ type Options = {
|
||||
/**
|
||||
* Handles a file from the upload folder and applies its data
|
||||
*/
|
||||
export async function applyProjectFile(filePath: string, options?: Options) {
|
||||
export async function applyProjectFile(name: string, options?: Options) {
|
||||
const filePath = join(resolveProjectsDirectory, name);
|
||||
const data = parseProjectFile(filePath);
|
||||
|
||||
// move file to project folder
|
||||
const filename = basename(filePath);
|
||||
const newFilePath = join(resolveProjectsDirectory, filename);
|
||||
await rename(filePath, newFilePath);
|
||||
|
||||
// change LowDB to point to new file
|
||||
await switchDb(filename);
|
||||
await switchDb(name);
|
||||
|
||||
// apply data model
|
||||
await applyDataModel(data, options);
|
||||
|
||||
// persist the project selection
|
||||
await appStateService.updateDatabaseConfig(filename);
|
||||
await appStateService.updateDatabaseConfig(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a file from upload folder to the projects folder
|
||||
* @param filePath
|
||||
* @param name
|
||||
* @returns
|
||||
*/
|
||||
export async function handleUploadedFile(filePath: string, name: string) {
|
||||
const newFilePath = join(resolveProjectsDirectory, name);
|
||||
await rename(filePath, newFilePath);
|
||||
await deleteFile(filePath);
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,18 +205,27 @@ export function extractPin(value: string | undefined | null, fallback: string |
|
||||
/**
|
||||
* applies a partial database model
|
||||
*/
|
||||
export async function applyDataModel(data: Partial<DatabaseModel>, options?: Options) {
|
||||
export async function applyDataModel(data: Partial<DatabaseModel>, _options?: Options) {
|
||||
runtimeService.stop();
|
||||
|
||||
const newRundown = data.rundown || [];
|
||||
const { rundown, ...rest } = data;
|
||||
if (options?.onlyRundown === 'true') {
|
||||
setRundown(newRundown ?? []);
|
||||
} else {
|
||||
await DataProvider.mergeIntoData(rest);
|
||||
setRundown(rundown ?? []);
|
||||
// TODO: allow partial project merge from options
|
||||
const { rundown, customFields, ...rest } = data;
|
||||
const newData = await DataProvider.mergeIntoData(rest);
|
||||
|
||||
if (rundown != null) {
|
||||
initRundown(rundown, customFields ?? {});
|
||||
}
|
||||
notifyChanges({ timer: true, external: true });
|
||||
|
||||
return newData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a project of a given name exists
|
||||
* @param name
|
||||
*/
|
||||
export function doesProjectExist(name: string): boolean {
|
||||
const projectFilePath = join(resolveProjectsDirectory, name);
|
||||
return existsSync(projectFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,3 +258,11 @@ export const validateProjectFiles = (projectFiles: { filename?: string; newFilen
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current project title or fallback
|
||||
*/
|
||||
export function getProjectTitle(): string {
|
||||
const { title } = DataProvider.getProjectData();
|
||||
return title || 'ontime data';
|
||||
}
|
||||
|
||||
@@ -66,11 +66,12 @@ export async function addEvent(
|
||||
const scopedMutation = cache.mutateCache(cache.add);
|
||||
const { newEvent } = await scopedMutation({ atIndex, event: eventToAdd as OntimeRundownEntry });
|
||||
|
||||
notifyChanges({ timer: [newEvent.id], external: true });
|
||||
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange();
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges({ timer: [newEvent.id], external: true });
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
@@ -82,10 +83,11 @@ export async function deleteEvent(eventId: string) {
|
||||
const scopedMutation = cache.mutateCache(cache.remove);
|
||||
await scopedMutation({ eventId });
|
||||
|
||||
notifyChanges({ timer: [eventId], external: true });
|
||||
|
||||
// notify event loader that rundown has changed
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange();
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges({ timer: [eventId], external: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,8 +97,11 @@ export async function deleteAllEvents() {
|
||||
const scopedMutation = cache.mutateCache(cache.removeAll);
|
||||
await scopedMutation({});
|
||||
|
||||
// no need to modify timer since we will reset
|
||||
notifyChanges({ external: true });
|
||||
// notify event loader that rundown has changed
|
||||
updateRuntimeOnChange();
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges({ timer: true, external: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,11 +117,12 @@ export async function editEvent(patch: Partial<OntimeEvent> | Partial<OntimeBloc
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know patch has an id
|
||||
const { newEvent } = await scopedMutation({ patch, eventId: patch.id! });
|
||||
|
||||
notifyChanges({ timer: [patch.id], external: true });
|
||||
|
||||
// notify event loader that rundown has changed
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange();
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges({ timer: [patch.id], external: true });
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
@@ -129,10 +135,11 @@ export async function batchEditEvents(ids: string[], data: Partial<OntimeEvent>)
|
||||
const scopedMutation = cache.mutateCache(cache.batchEdit);
|
||||
await scopedMutation({ patch: data, eventIds: ids });
|
||||
|
||||
notifyChanges({ timer: ids, external: true });
|
||||
|
||||
// notify event loader that rundown has changed
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange();
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges({ timer: ids, external: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,11 +152,12 @@ export async function reorderEvent(eventId: string, from: number, to: number) {
|
||||
const scopedMutation = cache.mutateCache(cache.reorder);
|
||||
const reorderedItem = await scopedMutation({ eventId, from, to });
|
||||
|
||||
notifyChanges({ timer: true, external: true });
|
||||
|
||||
// notify event loader that rundown has changed
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange();
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges({ timer: true, external: true });
|
||||
|
||||
return reorderedItem;
|
||||
}
|
||||
|
||||
@@ -157,6 +165,10 @@ export async function applyDelay(eventId: string) {
|
||||
const scopedMutation = cache.mutateCache(cache.applyDelay);
|
||||
await scopedMutation({ eventId });
|
||||
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange();
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges({ timer: true, external: true });
|
||||
}
|
||||
|
||||
@@ -170,10 +182,11 @@ export async function swapEvents(from: string, to: string) {
|
||||
const scopedMutation = cache.mutateCache(cache.swap);
|
||||
await scopedMutation({ fromId: from, toId: to });
|
||||
|
||||
notifyChanges({ timer: true, external: true });
|
||||
|
||||
// notify event loader that rundown has changed
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange();
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges({ timer: true, external: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,20 +194,34 @@ export async function swapEvents(from: string, to: string) {
|
||||
* Called when we make changes to the rundown object
|
||||
*/
|
||||
function updateRuntimeOnChange() {
|
||||
const playableEvents = getPlayableEvents();
|
||||
const numEvents = playableEvents.length;
|
||||
const metadata = cache.getMetadata();
|
||||
|
||||
// schedule an update for the end of the event loop
|
||||
setImmediate(() => updateRundownData(getPlayableEvents()));
|
||||
setImmediate(() =>
|
||||
updateRundownData({
|
||||
numEvents,
|
||||
...metadata,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify services of changes in the rundown
|
||||
*/
|
||||
export function notifyChanges(options: { timer?: boolean | string[]; external?: boolean }) {
|
||||
function notifyChanges(options: { timer?: boolean | string[]; external?: boolean }) {
|
||||
if (options.timer) {
|
||||
const playableEvents = getPlayableEvents();
|
||||
// notify timer service of changed events
|
||||
// timer can be true or an array of changed IDs
|
||||
const affected = Array.isArray(options.timer) ? options.timer : undefined;
|
||||
runtimeService.maybeUpdate(playableEvents, affected);
|
||||
|
||||
if (playableEvents.length === 0) {
|
||||
runtimeService.stop();
|
||||
} else {
|
||||
// notify timer service of changed events
|
||||
// timer can be true or an array of changed IDs
|
||||
const affected = Array.isArray(options.timer) ? options.timer : undefined;
|
||||
runtimeService.maybeUpdate(playableEvents, affected);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.external) {
|
||||
@@ -209,14 +236,10 @@ export function notifyChanges(options: { timer?: boolean | string[]; external?:
|
||||
*/
|
||||
export async function initRundown(rundown: OntimeRundown, customFields: CustomFields) {
|
||||
await cache.init(rundown, customFields);
|
||||
notifyChanges({ timer: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides the rundown with the given
|
||||
* @param rundown
|
||||
*/
|
||||
export async function setRundown(rundown: OntimeRundown) {
|
||||
await cache.setRundown(rundown);
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange();
|
||||
|
||||
// notify timer of change
|
||||
notifyChanges({ timer: true });
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
TimeStrategy,
|
||||
TimerType,
|
||||
} from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, dayInMs, millisToString } from 'ontime-utils';
|
||||
|
||||
import { calculateRuntimeDelays, getDelayAt, calculateRuntimeDelaysFrom } from '../delayUtils.js';
|
||||
import {
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
removeCustomField,
|
||||
} from '../rundownCache.js';
|
||||
|
||||
describe('init() function', () => {
|
||||
describe('generate()', () => {
|
||||
it('creates normalised versions of a given rundown', () => {
|
||||
const testRundown: OntimeRundown = [
|
||||
{ type: SupportedEvent.Event, id: '1' } as OntimeEvent,
|
||||
@@ -71,6 +72,7 @@ describe('init() function', () => {
|
||||
expect((initResult.rundown['3'] as OntimeEvent).delay).toBe(100);
|
||||
expect((initResult.rundown['4'] as OntimeEvent).delay).toBe(0);
|
||||
expect(initResult.totalDelay).toBe(0);
|
||||
expect(initResult.totalDuration).toBe(700 - 100);
|
||||
});
|
||||
|
||||
it('handles negative delays', () => {
|
||||
@@ -91,6 +93,7 @@ describe('init() function', () => {
|
||||
expect((initResult.rundown['3'] as OntimeEvent).delay).toBe(-200);
|
||||
expect((initResult.rundown['4'] as OntimeEvent).delay).toBe(-200);
|
||||
expect(initResult.totalDelay).toBe(-200);
|
||||
expect(initResult.totalDuration).toBe(700 - 100);
|
||||
});
|
||||
|
||||
it('links times across events', () => {
|
||||
@@ -153,6 +156,68 @@ describe('init() function', () => {
|
||||
expect(initResult.links['3']).toBe('2');
|
||||
});
|
||||
|
||||
it('calculates total duration', () => {
|
||||
const testRundown: OntimeRundown = [
|
||||
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '3', timeStart: 300, timeEnd: 400 } as OntimeEvent,
|
||||
];
|
||||
|
||||
const initResult = generate(testRundown);
|
||||
expect(initResult.order.length).toBe(3);
|
||||
expect(initResult.totalDuration).toBe(400 - 100);
|
||||
});
|
||||
|
||||
it('calculates total duration across days with gap', () => {
|
||||
const testRundown: OntimeRundown = [
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '1',
|
||||
timeStart: new Date(0).setHours(9),
|
||||
timeEnd: new Date(0).setHours(23),
|
||||
} as OntimeEvent,
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '2',
|
||||
timeStart: new Date(0).setHours(9),
|
||||
timeEnd: new Date(0).setHours(23),
|
||||
} as OntimeEvent,
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '3',
|
||||
timeStart: new Date(0).setHours(9),
|
||||
timeEnd: new Date(0).setHours(23),
|
||||
} as OntimeEvent,
|
||||
];
|
||||
|
||||
const initResult = generate(testRundown);
|
||||
const expectedDuration = (23 - 9 + 48) * MILLIS_PER_HOUR;
|
||||
expect(millisToString(initResult.totalDuration)).toBe('62:00:00');
|
||||
expect(initResult.totalDuration).toBe(expectedDuration);
|
||||
});
|
||||
|
||||
it('calculates total duration across days', () => {
|
||||
const testRundown: OntimeRundown = [
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '1',
|
||||
timeStart: new Date(0).setHours(12),
|
||||
timeEnd: new Date(0).setHours(22),
|
||||
} as OntimeEvent,
|
||||
{
|
||||
type: SupportedEvent.Event,
|
||||
id: '2',
|
||||
timeStart: new Date(0).setHours(22),
|
||||
timeEnd: new Date(0).setHours(8),
|
||||
} as OntimeEvent,
|
||||
];
|
||||
|
||||
const initResult = generate(testRundown);
|
||||
const expectedDuration = 8 * MILLIS_PER_HOUR + (dayInMs - 12 * MILLIS_PER_HOUR);
|
||||
expect(millisToString(initResult.totalDuration)).toBe('20:00:00');
|
||||
expect(initResult.totalDuration).toBe(expectedDuration);
|
||||
});
|
||||
|
||||
it('handles updating event sequence', () => {
|
||||
const testRundown: OntimeRundown = [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { CustomFields, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
|
||||
import { addToCustomAssignment, getLink, handleCustomField, handleLink } from '../rundownCacheUtils.js';
|
||||
import {
|
||||
CustomFields,
|
||||
EndAction,
|
||||
OntimeEvent,
|
||||
OntimeRundown,
|
||||
SupportedEvent,
|
||||
TimeStrategy,
|
||||
TimerType,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
addToCustomAssignment,
|
||||
getLink,
|
||||
handleCustomField,
|
||||
handleLink,
|
||||
hasChanges,
|
||||
isDataStale,
|
||||
} from '../rundownCacheUtils.js';
|
||||
|
||||
describe('getLink()', () => {
|
||||
it('should return null if there is no link', () => {
|
||||
@@ -187,3 +202,52 @@ describe('handleCustomField()', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDataStale()', () => {
|
||||
it('is stale if data contains timers', () => {
|
||||
const needsRecompute = [
|
||||
{ timeStart: 10 },
|
||||
{ timeEnd: 10 },
|
||||
{ duration: 10 },
|
||||
{ linkStart: '1' },
|
||||
{ timerStrategy: TimeStrategy.LockDuration },
|
||||
];
|
||||
|
||||
for (const testCase of needsRecompute) {
|
||||
expect(isDataStale(testCase)).toBe(true);
|
||||
}
|
||||
expect.assertions(needsRecompute.length);
|
||||
});
|
||||
|
||||
it('is not stale if data contains auxiliary dataset', () => {
|
||||
expect(
|
||||
isDataStale({
|
||||
cue: 'cue',
|
||||
title: 'title',
|
||||
note: 'note',
|
||||
endAction: EndAction.LoadNext,
|
||||
timerType: TimerType.Clock,
|
||||
isPublic: false,
|
||||
colour: 'colour',
|
||||
timeWarning: 1,
|
||||
timeDanger: 2,
|
||||
custom: {
|
||||
lighting: { value: '3' },
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasChanges()', () => {
|
||||
it('identifies objects with new values', () => {
|
||||
const newEvent = { id: '1', title: 'new-title' } as OntimeEvent;
|
||||
const existing = { id: '1', cue: 'cue', title: 'title' } as OntimeEvent;
|
||||
expect(hasChanges(existing, newEvent)).toBe(true);
|
||||
});
|
||||
it('identifies objects with all same values', () => {
|
||||
const newEvent = { id: '1', title: 'title' } as OntimeEvent;
|
||||
const existing = { id: '1', cue: 'cue', title: 'title' } as OntimeEvent;
|
||||
expect(hasChanges(existing, newEvent)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CustomFields,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
MaybeNumber,
|
||||
OntimeEvent,
|
||||
OntimeRundown,
|
||||
OntimeRundownEntry,
|
||||
@@ -12,8 +13,9 @@ import { generateId, deleteAtIndex, insertAtIndex, reorderArray, swapEventData }
|
||||
|
||||
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { createPatch } from '../../utils/parser.js';
|
||||
import { getTotalDuration } from '../timerUtils.js';
|
||||
import { apply } from './delayUtils.js';
|
||||
import { handleCustomField, handleLink } from './rundownCacheUtils.js';
|
||||
import { handleCustomField, handleLink, hasChanges, isDataStale } from './rundownCacheUtils.js';
|
||||
|
||||
type EventID = string;
|
||||
type NormalisedRundown = Record<EventID, OntimeRundownEntry>;
|
||||
@@ -30,6 +32,9 @@ let order: EventID[] = [];
|
||||
let revision = 0;
|
||||
let isStale = true;
|
||||
let totalDelay = 0;
|
||||
let totalDuration = 0;
|
||||
let firstStart: MaybeNumber = null;
|
||||
let lastEnd: MaybeNumber = null;
|
||||
|
||||
let links: Record<EventID, EventID> = {};
|
||||
|
||||
@@ -78,8 +83,11 @@ export function generate(
|
||||
rundown = {};
|
||||
order = [];
|
||||
links = {};
|
||||
firstStart = null;
|
||||
lastEnd = null;
|
||||
|
||||
let accumulatedDelay = 0;
|
||||
let daySpan = 0;
|
||||
let previousEnd: number;
|
||||
|
||||
for (let i = 0; i < initialRundown.length; i++) {
|
||||
@@ -95,6 +103,19 @@ export function generate(
|
||||
|
||||
// update the persisted event
|
||||
initialRundown[i] = updatedEvent;
|
||||
|
||||
// update rundown duration
|
||||
if (firstStart === null) {
|
||||
firstStart = updatedEvent.timeStart;
|
||||
}
|
||||
lastEnd = updatedEvent.timeEnd;
|
||||
|
||||
// check if we go over midnight, account for eventual gaps
|
||||
const gapOverMidnight = previousEnd > updatedEvent.timeStart;
|
||||
const durationOverMidnight = updatedEvent.timeStart > updatedEvent.timeEnd;
|
||||
if (gapOverMidnight || durationOverMidnight) {
|
||||
daySpan++;
|
||||
}
|
||||
}
|
||||
|
||||
// calculate delays
|
||||
@@ -119,7 +140,9 @@ export function generate(
|
||||
|
||||
isStale = false;
|
||||
totalDelay = accumulatedDelay;
|
||||
return { rundown, order, links, totalDelay, assignedCustomProperties: assignedCustomFields };
|
||||
totalDuration = getTotalDuration(firstStart, lastEnd, daySpan);
|
||||
|
||||
return { rundown, order, links, totalDelay, totalDuration, assignedCustomProperties: assignedCustomFields };
|
||||
}
|
||||
|
||||
/** Returns an ID guaranteed to be unique */
|
||||
@@ -146,6 +169,8 @@ type RundownCache = {
|
||||
rundown: NormalisedRundown;
|
||||
order: string[];
|
||||
revision: number;
|
||||
totalDelay: number;
|
||||
totalDuration: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -162,6 +187,26 @@ export function get(): Readonly<RundownCache> {
|
||||
rundown,
|
||||
order,
|
||||
revision,
|
||||
totalDelay,
|
||||
totalDuration,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns calculated metadata from rundown
|
||||
*/
|
||||
export function getMetadata() {
|
||||
if (isStale) {
|
||||
console.time('rundownCache__init');
|
||||
generate();
|
||||
console.timeEnd('rundownCache__init');
|
||||
}
|
||||
|
||||
return {
|
||||
firstStart,
|
||||
lastEnd,
|
||||
totalDelay,
|
||||
totalDuration,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,20 +225,25 @@ type MutatingFn<T extends object> = (params: MutationParams<T>) => MutatingRetur
|
||||
*/
|
||||
export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
|
||||
async function scopedMutation(params: T) {
|
||||
/**
|
||||
* Marking the data set as stale
|
||||
* doing it before calling the mutation, gives the function a chance
|
||||
* to prevent recalculation by setting stale = false
|
||||
*/
|
||||
isStale = true;
|
||||
|
||||
const { newEvent, newRundown } = mutation({ ...params, persistedRundown });
|
||||
|
||||
revision = revision + 1;
|
||||
isStale = true;
|
||||
persistedRundown = newRundown;
|
||||
|
||||
// schedule a non priority cache update
|
||||
setImmediate(() => {
|
||||
console.time('rundownCache__init');
|
||||
generate();
|
||||
get();
|
||||
console.timeEnd('rundownCache__init');
|
||||
});
|
||||
|
||||
// TODO: should we throttle this?
|
||||
// defer writing to the database
|
||||
setImmediate(() => {
|
||||
DataProvider.setRundown(persistedRundown);
|
||||
@@ -257,11 +307,23 @@ export function edit({ persistedRundown, eventId, patch }: EditArgs): Required<M
|
||||
}
|
||||
|
||||
const eventInMemory = persistedRundown[indexAt];
|
||||
if (!hasChanges(eventInMemory, patch)) {
|
||||
isStale = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const newEvent = makeEvent(eventInMemory, patch);
|
||||
|
||||
const newRundown = [...persistedRundown];
|
||||
newRundown[indexAt] = newEvent;
|
||||
|
||||
const makeStale = isDataStale(patch);
|
||||
|
||||
if (!makeStale) {
|
||||
rundown[newEvent.id] = newEvent;
|
||||
}
|
||||
|
||||
isStale = makeStale;
|
||||
return { newRundown, newEvent };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { OntimeEvent, isOntimeEvent, OntimeRundown, CustomFieldLabel, CustomFields } from 'ontime-types';
|
||||
import {
|
||||
OntimeEvent,
|
||||
isOntimeEvent,
|
||||
OntimeRundown,
|
||||
CustomFieldLabel,
|
||||
CustomFields,
|
||||
OntimeRundownEntry,
|
||||
OntimeBaseEvent,
|
||||
} from 'ontime-types';
|
||||
import { getLinkedTimes } from 'ontime-utils';
|
||||
|
||||
/**
|
||||
@@ -90,3 +98,38 @@ export function handleCustomField(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** List of event properties which do not need the rundown to be regenerated */
|
||||
enum regenerateWhitelist {
|
||||
'id',
|
||||
'cue',
|
||||
'title',
|
||||
'note',
|
||||
'endAction',
|
||||
'timerType',
|
||||
'isPublic',
|
||||
'colour',
|
||||
'timeWarning',
|
||||
'timeDanger',
|
||||
'custom',
|
||||
}
|
||||
|
||||
/**
|
||||
* given a patch, returns whether all keys are whitelisted
|
||||
* @param path
|
||||
*/
|
||||
export function isDataStale(patch: Partial<OntimeRundownEntry>): boolean {
|
||||
return Object.keys(patch).some((key) => !(key in regenerateWhitelist));
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an event and a patch to that event checks whether there are actual changes to the dataset
|
||||
* @param existingEvent
|
||||
* @param newEvent
|
||||
* @returns
|
||||
*/
|
||||
export function hasChanges<T extends OntimeBaseEvent>(existingEvent: T, newEvent: Partial<T>): boolean {
|
||||
return Object.keys(newEvent).some(
|
||||
(key) => !Object.hasOwn(existingEvent, key) || existingEvent[key] !== newEvent[key],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ class RuntimeService {
|
||||
this.eventTimer = new TimerService({
|
||||
refresh: timerConfig.updateRate,
|
||||
updateInterval: timerConfig.notificationRate,
|
||||
onUpdateCallback: this.checkTimerUpdate.bind(this),
|
||||
onUpdateCallback: (updateResult) => this.checkTimerUpdate(updateResult),
|
||||
});
|
||||
|
||||
if (resumable) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import got from 'got';
|
||||
|
||||
import { resolveSheetsDirectory } from '../../setup/index.js';
|
||||
import { ensureDirectory } from '../../utils/fileManagement.js';
|
||||
import { type ClientSecret, cellRequestFromEvent, getA1Notation, validateClientSecret } from './sheetUtils.js';
|
||||
import { cellRequestFromEvent, type ClientSecret, getA1Notation, validateClientSecret } from './sheetUtils.js';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
import { parseExcel } from '../../utils/parser.js';
|
||||
import { logger } from '../../classes/Logger.js';
|
||||
@@ -190,11 +190,11 @@ function verifyConnection(
|
||||
}
|
||||
}
|
||||
|
||||
export function hasAuth(): { authenticated: AuthenticationStatus } {
|
||||
export function hasAuth(): { authenticated: AuthenticationStatus; sheetId: string } {
|
||||
if (cleanupTimeout) {
|
||||
return { authenticated: 'pending' };
|
||||
return { authenticated: 'pending', sheetId: currentSheetId };
|
||||
}
|
||||
return { authenticated: currentAuthClient ? 'authenticated' : 'not_authenticated' };
|
||||
return { authenticated: currentAuthClient ? 'authenticated' : 'not_authenticated', sheetId: currentSheetId };
|
||||
}
|
||||
|
||||
async function verifySheet(
|
||||
|
||||
@@ -108,7 +108,7 @@ describe('cellRequestFromEvent()', () => {
|
||||
expect(result).toStrictEqual(millisToString(event.duration));
|
||||
});
|
||||
|
||||
test('boolean to x', () => {
|
||||
test('boolean to TRUE', () => {
|
||||
const event: OntimeEvent = {
|
||||
type: SupportedEvent.Event,
|
||||
cue: '1',
|
||||
@@ -149,8 +149,8 @@ describe('cellRequestFromEvent()', () => {
|
||||
timeDanger: { row: 1, col: 41 },
|
||||
};
|
||||
const result = cellRequestFromEvent(event, 1, 1234, metadata);
|
||||
expect(result.updateCells.rows[0].values[11].userEnteredValue.stringValue).toStrictEqual('x');
|
||||
expect(result.updateCells.rows[0].values[12].userEnteredValue.stringValue).toStrictEqual('');
|
||||
expect(result.updateCells.rows[0].values[11].userEnteredValue.boolValue).toStrictEqual(true);
|
||||
expect(result.updateCells.rows[0].values[12].userEnteredValue.boolValue).toStrictEqual(false);
|
||||
});
|
||||
|
||||
test('spacing in metadata', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OntimeRundownEntry, isOntimeBlock, isOntimeEvent } from 'ontime-types';
|
||||
import { isOntimeBlock, isOntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import { sheets_v4 } from '@googleapis/sheets';
|
||||
@@ -111,7 +111,7 @@ export function cellRequestFromEvent(
|
||||
});
|
||||
} else if (typeof event[key] === 'boolean') {
|
||||
returnRows.push({
|
||||
userEnteredValue: { stringValue: event[key] ? 'x' : '' },
|
||||
userEnteredValue: { boolValue: event[key] },
|
||||
});
|
||||
} else {
|
||||
returnRows.push({});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MaybeNumber, MaybeString, OntimeEvent, TimerType } from 'ontime-types';
|
||||
import { dayInMs, sortArrayByProperty } from 'ontime-utils';
|
||||
import { RuntimeState } from '../stores/runtimeState.js';
|
||||
import { timerConfig } from '../config/config.js';
|
||||
|
||||
/**
|
||||
* handle events that span over midnight
|
||||
@@ -62,12 +63,6 @@ export function getCurrent(state: RuntimeState): number {
|
||||
|
||||
if (timerType === TimerType.TimeToEnd) {
|
||||
const isEventOverMidnight = timeStart > timeEnd;
|
||||
const hasFinishedRundownForToday = state.runtime.plannedEnd && clock > state.runtime.plannedEnd;
|
||||
|
||||
if (hasFinishedRundownForToday && !isEventOverMidnight) {
|
||||
return dayInMs - clock + state.eventNow.timeStart + addedTime;
|
||||
}
|
||||
|
||||
const correctDay = isEventOverMidnight ? dayInMs : 0;
|
||||
return correctDay - clock + timeEnd + addedTime;
|
||||
}
|
||||
@@ -76,12 +71,12 @@ export function getCurrent(state: RuntimeState): number {
|
||||
return duration;
|
||||
}
|
||||
|
||||
const hasPassedMidnight = startedAt > clock;
|
||||
const correctDay = hasPassedMidnight ? dayInMs : 0;
|
||||
if (pausedAt != null) {
|
||||
return startedAt + duration + addedTime - pausedAt;
|
||||
}
|
||||
|
||||
const hasPassedMidnight = startedAt > clock;
|
||||
const correctDay = hasPassedMidnight ? dayInMs : 0;
|
||||
return startedAt + duration + addedTime - clock - correctDay;
|
||||
}
|
||||
|
||||
@@ -278,7 +273,7 @@ export const updateRoll = (state: RuntimeState) => {
|
||||
updatedTimer -= dayInMs;
|
||||
}
|
||||
|
||||
if (updatedTimer < 0) {
|
||||
if (updatedTimer <= timerConfig.triggerAhead) {
|
||||
isPrimaryFinished = true;
|
||||
// we need a new event
|
||||
doRollLoad = true;
|
||||
@@ -305,7 +300,7 @@ export const updateRoll = (state: RuntimeState) => {
|
||||
* @returns
|
||||
*/
|
||||
export function getRuntimeOffset(state: RuntimeState): MaybeNumber {
|
||||
if (state.runtime.actualStart === null) {
|
||||
if (state.eventNow === null || state.runtime.actualStart === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -329,3 +324,34 @@ export function getRuntimeOffset(state: RuntimeState): MaybeNumber {
|
||||
|
||||
return startOffset + addedTime + pausedTime + Math.abs(overtime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates total duration of a time span
|
||||
* @param firstStart
|
||||
* @param lastEnd
|
||||
* @param daySpan
|
||||
* @returns
|
||||
*/
|
||||
export function getTotalDuration(firstStart: number, lastEnd: number, daySpan: number): number {
|
||||
if (!lastEnd) {
|
||||
return 0;
|
||||
}
|
||||
let correctDay = 0;
|
||||
if (lastEnd < firstStart) {
|
||||
correctDay = dayInMs;
|
||||
daySpan -= 1;
|
||||
}
|
||||
// eslint-disable-next-line prettier/prettier -- we like the clarity
|
||||
return lastEnd + correctDay + daySpan * dayInMs - firstStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the expected end of the rundown
|
||||
*/
|
||||
export function getExpectedEnd(state: RuntimeState): MaybeNumber {
|
||||
// there is no expected end if we havent started
|
||||
if (state.runtime.actualStart === null) {
|
||||
return null;
|
||||
}
|
||||
return state.runtime.plannedEnd + state.runtime.offset + state._timer.totalDelay;
|
||||
}
|
||||
|
||||
@@ -83,14 +83,21 @@ export const resolveExternalsDirectory = join(isProduction ? getAppDataPath() :
|
||||
export const appStatePath = join(getAppDataPath(), config.appState);
|
||||
export const uploadsFolderPath = join(getAppDataPath(), config.uploads);
|
||||
|
||||
const ensureAppState = () => {
|
||||
ensureDirectory(getAppDataPath());
|
||||
fs.writeFileSync(appStatePath, JSON.stringify({ lastLoadedProject: 'db.json' }));
|
||||
};
|
||||
|
||||
const getLastLoadedProject = () => {
|
||||
try {
|
||||
const appState = JSON.parse(fs.readFileSync(appStatePath, 'utf8'));
|
||||
if (!appState.lastLoadedProject) {
|
||||
ensureAppState();
|
||||
}
|
||||
return appState.lastLoadedProject;
|
||||
} catch {
|
||||
if (!isTest) {
|
||||
ensureDirectory(getAppDataPath());
|
||||
fs.writeFileSync(appStatePath, JSON.stringify({ lastLoadedProject: 'db.json' }));
|
||||
ensureAppState();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { OntimeEvent, Playback } from 'ontime-types';
|
||||
import { deepmerge } from 'ontime-utils';
|
||||
|
||||
import { RuntimeState, addTime, clear, getState, load, pause, start, stop } from '../runtimeState.js';
|
||||
import { initRundown } from '../../services/rundown-service/RundownService.js';
|
||||
|
||||
const mockEvent = {
|
||||
type: 'event',
|
||||
@@ -48,17 +49,22 @@ describe('mutation on runtimeState', () => {
|
||||
beforeEach(() => {
|
||||
clear();
|
||||
|
||||
vi.mock('../../services/rundown-service/RundownService.js', () => ({
|
||||
getPlayableEvents: vi.fn().mockReturnValue([
|
||||
{
|
||||
id: 'mock',
|
||||
cue: 'mock',
|
||||
timeStart: 0,
|
||||
timeEnd: 1000,
|
||||
duration: 1000,
|
||||
},
|
||||
]),
|
||||
}));
|
||||
vi.mock('../../services/rundown-service/RundownService.js', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as object;
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getPlayableEvents: vi.fn().mockReturnValue([
|
||||
{
|
||||
id: 'mock',
|
||||
cue: 'mock',
|
||||
timeStart: 0,
|
||||
timeEnd: 1000,
|
||||
duration: 1000,
|
||||
},
|
||||
]),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -137,10 +143,12 @@ describe('mutation on runtimeState', () => {
|
||||
expect(newState.runtime.actualStart).toBeNull();
|
||||
});
|
||||
|
||||
// do this before the test so that it is applied
|
||||
const event1 = { ...mockEvent, id: 'event1', timeStart: 0, timeEnd: 1000, duration: 1000 };
|
||||
const event2 = { ...mockEvent, id: 'event2', timeStart: 1000, timeEnd: 1500, duration: 500 };
|
||||
// force update
|
||||
initRundown([event1, event2], {});
|
||||
test('runtime offset', () => {
|
||||
const event1 = { ...mockEvent, id: 'event1', timeStart: 0, timeEnd: 1000, duration: 1000 };
|
||||
const event2 = { ...mockEvent, id: 'event2', timeStart: 1000, timeEnd: 1500, duration: 500 };
|
||||
|
||||
// 1. Load event
|
||||
load(event1, [event1, event2]);
|
||||
let newState = getState();
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { MaybeNumber, OntimeEvent, Playback, Runtime, TimerState, TimerType } from 'ontime-types';
|
||||
import { calculateDuration, dayInMs, getFirstEvent, getLastEvent } from 'ontime-utils';
|
||||
import { calculateDuration, dayInMs } from 'ontime-utils';
|
||||
|
||||
import { clock } from '../services/Clock.js';
|
||||
import { RestorePoint } from '../services/RestoreService.js';
|
||||
|
||||
import {
|
||||
getCurrent,
|
||||
getExpectedEnd,
|
||||
getExpectedFinish,
|
||||
getRollTimers,
|
||||
getRuntimeOffset,
|
||||
@@ -46,6 +47,7 @@ export type RuntimeState = {
|
||||
timer: TimerState;
|
||||
// private properties of the timer calculations
|
||||
_timer: {
|
||||
totalDelay: number; // this value comes from rundown service
|
||||
pausedAt: MaybeNumber;
|
||||
secondaryTarget: MaybeNumber;
|
||||
};
|
||||
@@ -60,6 +62,7 @@ const runtimeState: RuntimeState = {
|
||||
runtime: initialRuntime,
|
||||
timer: { ...initialTimer },
|
||||
_timer: {
|
||||
totalDelay: 0,
|
||||
pausedAt: null,
|
||||
secondaryTarget: null,
|
||||
},
|
||||
@@ -85,10 +88,10 @@ export function clear() {
|
||||
runtimeState.timer.playback = Playback.Stop;
|
||||
runtimeState.clock = clock.timeNow();
|
||||
runtimeState.timer = { ...initialTimer };
|
||||
runtimeState._timer = {
|
||||
pausedAt: null,
|
||||
secondaryTarget: null,
|
||||
};
|
||||
|
||||
// we maintain the total delay
|
||||
runtimeState._timer.pausedAt = null;
|
||||
runtimeState._timer.secondaryTarget = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,23 +106,25 @@ function patchTimer(newState: Partial<TimerState>) {
|
||||
}
|
||||
}
|
||||
|
||||
type RundownData = {
|
||||
numEvents: number;
|
||||
firstStart: MaybeNumber;
|
||||
lastEnd: MaybeNumber;
|
||||
totalDelay: number;
|
||||
totalDuration: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility, allows updating data derived from the rundown
|
||||
* @param playableRundown
|
||||
*/
|
||||
export function updateRundownData(playableRundown: OntimeEvent[]) {
|
||||
runtimeState.runtime.numEvents = playableRundown.length;
|
||||
export function updateRundownData(rundownData: RundownData) {
|
||||
runtimeState._timer.totalDelay = rundownData.totalDelay;
|
||||
|
||||
const { firstEvent } = getFirstEvent(playableRundown);
|
||||
const { lastEvent } = getLastEvent(playableRundown);
|
||||
|
||||
runtimeState.runtime.plannedStart = firstEvent?.timeStart ?? null;
|
||||
runtimeState.runtime.plannedEnd = lastEvent?.timeEnd ?? null;
|
||||
if (runtimeState.runtime.plannedEnd === null) {
|
||||
runtimeState.runtime.expectedEnd = null;
|
||||
} else {
|
||||
runtimeState.runtime.expectedEnd = (runtimeState.runtime.plannedEnd + runtimeState.runtime.offset) % dayInMs;
|
||||
}
|
||||
runtimeState.runtime.numEvents = rundownData.numEvents;
|
||||
runtimeState.runtime.plannedStart = rundownData.firstStart;
|
||||
runtimeState.runtime.plannedEnd = rundownData.firstStart + rundownData.totalDuration;
|
||||
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,12 +140,9 @@ export function load(
|
||||
): boolean {
|
||||
clear();
|
||||
|
||||
updateRundownData(rundown);
|
||||
|
||||
const eventIndex = rundown.findIndex((eventInMemory) => eventInMemory.id === event.id);
|
||||
|
||||
runtimeState.runtime.selectedEventIndex = eventIndex;
|
||||
runtimeState.runtime.numEvents = rundown.length;
|
||||
|
||||
loadNow(event, rundown);
|
||||
loadNext(rundown);
|
||||
@@ -157,7 +159,7 @@ export function load(
|
||||
if (firstStart === null || typeof firstStart === 'number') {
|
||||
runtimeState.runtime.actualStart = firstStart;
|
||||
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
|
||||
runtimeState.runtime.expectedEnd = (runtimeState.runtime.plannedEnd + runtimeState.runtime.offset) % dayInMs;
|
||||
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,9 +351,8 @@ export function addTime(amount: number) {
|
||||
|
||||
// update runtime delays: over - under
|
||||
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
|
||||
if (runtimeState.runtime.offset !== null) {
|
||||
runtimeState.runtime.expectedEnd = (runtimeState.runtime.plannedEnd + runtimeState.runtime.offset) % dayInMs;
|
||||
}
|
||||
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -367,9 +368,6 @@ export function update(): UpdateResult {
|
||||
const previousTime = runtimeState.clock;
|
||||
runtimeState.clock = clock.timeNow();
|
||||
|
||||
// update offset
|
||||
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
|
||||
|
||||
// we call integrations if we update timers
|
||||
if (runtimeState.timer.playback === Playback.Roll) {
|
||||
const result = onRollUpdate();
|
||||
@@ -385,6 +383,9 @@ export function update(): UpdateResult {
|
||||
runtimeState.timer.duration = runtimeState.timer.current;
|
||||
}
|
||||
|
||||
// update offset
|
||||
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
|
||||
|
||||
return {
|
||||
hasTimerFinished,
|
||||
shouldCallRoll,
|
||||
@@ -406,7 +407,8 @@ export function update(): UpdateResult {
|
||||
function onPlayUpdate() {
|
||||
let isFinished = false;
|
||||
runtimeState.timer.current = getCurrent(runtimeState);
|
||||
const finishedNow = runtimeState.timer.current <= 0 && runtimeState.timer.finishedAt === null;
|
||||
const finishedNow =
|
||||
runtimeState.timer.current <= timerConfig.triggerAhead && runtimeState.timer.finishedAt === null;
|
||||
|
||||
if (runtimeState.timer.playback === Playback.Play && finishedNow) {
|
||||
runtimeState.timer.finishedAt = runtimeState.clock;
|
||||
|
||||
@@ -5,20 +5,20 @@ import {
|
||||
DatabaseModel,
|
||||
EndAction,
|
||||
OntimeEvent,
|
||||
OntimeRundown,
|
||||
ProjectData,
|
||||
Settings,
|
||||
SupportedEvent,
|
||||
TimeStrategy,
|
||||
TimerType,
|
||||
TimeStrategy,
|
||||
ViewSettings,
|
||||
OntimeRundown,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { dbModel } from '../../models/dataModel.js';
|
||||
|
||||
import { parseExcel, parseJson, createEvent, getCustomFieldData } from '../parser.js';
|
||||
import { createEvent, getCustomFieldData, parseExcel, parseJson } from '../parser.js';
|
||||
import { makeString } from '../parserUtils.js';
|
||||
import { parseUrlPresets, parseViewSettings } from '../parserFunctions.js';
|
||||
import { parseRundown, parseUrlPresets, parseViewSettings } from '../parserFunctions.js';
|
||||
|
||||
describe('test json parser with valid def', () => {
|
||||
const testData: Partial<DatabaseModel> = {
|
||||
@@ -795,8 +795,8 @@ describe('parseExcel()', () => {
|
||||
'Public',
|
||||
'Skip',
|
||||
'Notes',
|
||||
'test0',
|
||||
'test1',
|
||||
't0',
|
||||
'UpperCaseFromSheet',
|
||||
'test2',
|
||||
'test3',
|
||||
'test4',
|
||||
@@ -809,8 +809,8 @@ describe('parseExcel()', () => {
|
||||
'cue',
|
||||
],
|
||||
[
|
||||
'1899-12-30T07:00:00.000Z',
|
||||
'1899-12-30T08:00:10.000Z',
|
||||
'07:00:00',
|
||||
'08:00:10',
|
||||
'Guest Welcome',
|
||||
'',
|
||||
'',
|
||||
@@ -831,8 +831,8 @@ describe('parseExcel()', () => {
|
||||
101,
|
||||
],
|
||||
[
|
||||
'1899-12-30T08:00:00.000Z',
|
||||
'1899-12-30T08:30:00.000Z',
|
||||
'08:00:00',
|
||||
'08:30:00',
|
||||
'A song from the hearth',
|
||||
'load-next',
|
||||
'clock',
|
||||
@@ -858,9 +858,9 @@ describe('parseExcel()', () => {
|
||||
// partial import map with only custom fields
|
||||
const importMap = {
|
||||
custom: {
|
||||
user0: 'test0',
|
||||
user1: 'test1',
|
||||
user2: 'test2',
|
||||
user0: 't0',
|
||||
user1: 'UpperCaseFromSheet',
|
||||
UpperCaseFromOntime: 'test2',
|
||||
user3: 'test3',
|
||||
user4: 'test4',
|
||||
user5: 'test5',
|
||||
@@ -874,8 +874,8 @@ describe('parseExcel()', () => {
|
||||
// TODO: update tests once import is resolved
|
||||
const expectedParsedRundown = [
|
||||
{
|
||||
//timeStart: 28800000,
|
||||
//timeEnd: 32410000,
|
||||
timeStart: 25200000,
|
||||
timeEnd: 28810000,
|
||||
title: 'Guest Welcome',
|
||||
timerType: 'count-down',
|
||||
endAction: 'none',
|
||||
@@ -885,7 +885,7 @@ describe('parseExcel()', () => {
|
||||
custom: {
|
||||
user0: { value: 'a0' },
|
||||
user1: { value: 'a1' },
|
||||
user2: { value: 'a2' },
|
||||
UpperCaseFromOntime: { value: 'a2' },
|
||||
user3: { value: 'a3' },
|
||||
user4: { value: 'a4' },
|
||||
user5: { value: 'a5' },
|
||||
@@ -899,8 +899,8 @@ describe('parseExcel()', () => {
|
||||
cue: '101',
|
||||
},
|
||||
{
|
||||
//timeStart: 32400000,
|
||||
//timeEnd: 34200000,
|
||||
timeStart: 28800000,
|
||||
timeEnd: 30600000,
|
||||
title: 'A song from the hearth',
|
||||
timerType: 'clock',
|
||||
endAction: 'load-next',
|
||||
@@ -929,10 +929,10 @@ describe('parseExcel()', () => {
|
||||
colour: '',
|
||||
label: 'user1',
|
||||
},
|
||||
user2: {
|
||||
UpperCaseFromOntime: {
|
||||
type: 'string',
|
||||
colour: '',
|
||||
label: 'user2',
|
||||
label: 'UpperCaseFromOntime',
|
||||
},
|
||||
user3: {
|
||||
type: 'string',
|
||||
@@ -1363,4 +1363,48 @@ describe('parseExcel()', () => {
|
||||
expect(result.rundown.at(1).type).toBe(SupportedEvent.Event);
|
||||
expect((result.rundown.at(1) as OntimeEvent).timerType).toBe(TimerType.CountDown);
|
||||
});
|
||||
|
||||
it('am/pm conversion to 24h', () => {
|
||||
const testData = [
|
||||
['Time Start', 'Time End', 'Title', 'End Action', 'Public', 'Skip', 'Notes', 'Colour', 'cue'],
|
||||
['4:30:00', '4:36:00', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102],
|
||||
['9:45:00', '10:56:00', 'Green grass', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103],
|
||||
['16:30:00', '16:36:00', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102],
|
||||
['21:45:00', '22:56:00', 'Green grass', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103],
|
||||
['4:30:00AM', '4:36:00AM', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102],
|
||||
['9:45:00AM', '10:56:00AM', 'Green grass', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103],
|
||||
['4:30:00PM', '4:36:00PM', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102],
|
||||
['9:45:00PM', '10:56:00PM', 'Green grass', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103],
|
||||
[],
|
||||
];
|
||||
|
||||
const importMap = {
|
||||
worksheet: 'event schedule',
|
||||
timeStart: 'time start',
|
||||
timeEnd: 'time end',
|
||||
duration: 'duration',
|
||||
cue: 'cue',
|
||||
title: 'title',
|
||||
isPublic: 'public',
|
||||
skip: 'skip',
|
||||
note: 'notes',
|
||||
colour: 'colour',
|
||||
endAction: 'end action',
|
||||
timerType: 'timer type',
|
||||
timeWarning: 'warning time',
|
||||
timeDanger: 'danger time',
|
||||
custom: {},
|
||||
};
|
||||
const result = parseExcel(testData, importMap);
|
||||
const rundown = parseRundown(result);
|
||||
const events = rundown.filter((e) => e.type === SupportedEvent.Event) as OntimeEvent[];
|
||||
expect(events.at(0).timeStart).toEqual(16200000);
|
||||
expect(events.at(1).timeStart).toEqual(35100000);
|
||||
expect(events.at(2).timeStart).toEqual(59400000);
|
||||
expect(events.at(3).timeStart).toEqual(78300000);
|
||||
expect(events.at(4).timeStart).toEqual(16200000);
|
||||
expect(events.at(5).timeStart).toEqual(35100000);
|
||||
expect(events.at(6).timeStart).toEqual(59400000);
|
||||
expect(events.at(7).timeStart).toEqual(78300000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('parseExcelDate', () => {
|
||||
});
|
||||
|
||||
describe('parses a time string that passes validation', () => {
|
||||
const validFields = ['10:00:00', '10:00'];
|
||||
const validFields = ['10:00:00', '10:00', '10:00AM', '10:00am', '10:00PM', '10:00pm'];
|
||||
validFields.forEach((field) => {
|
||||
it(`handles ${field}`, () => {
|
||||
const millis = parseExcelDate(field);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user