Compare commits

..

28 Commits

Author SHA1 Message Date
Alex Christoffer Rasmussen f5e9a2549e Improve spreadsheet import flow (#839)
* feat: add dropdown to worksheet selection

* feat: move revoke to separate button

* refactor: extract excel flow to separate controller and service

* chore: show spinner when uploading sheet and disable the other option

* chore: don't show "success" when canceling the import

* chore: export boolean types properly to sheets
2024-03-22 21:33:11 +01:00
Carlos Valente f4692db021 refactor: allow transition from roll to playback (#847) 2024-03-22 13:32:36 +01:00
Carlos Valente dddb11ff40 refactor: granular updates to rundown (#848) 2024-03-22 13:21:49 +01:00
Carlos Valente 6178ed1e4e Apply project (#843) 2024-03-22 12:52:55 +01:00
Carlos Valente 8c5aaa0901 fix: account for midnight rollover (#846) 2024-03-22 08:04:23 +01:00
Carlos Valente b06ab15190 refactor: save object on restore (#842) 2024-03-21 22:10:54 +01:00
Ary a48be0f017 Fix to lastLoadedProject being null (#830) 2024-03-21 21:08:45 +01:00
Alex Christoffer Rasmussen c2280da61f fix: uncaught error in http integration (#837) 2024-03-21 21:07:39 +01:00
Carlos Valente fe30752130 refactor: follow in run mode (#840) 2024-03-21 21:07:15 +01:00
Alex Christoffer Rasmussen a20d63451b fix: use cross-env to allow windows to build local docker (#838) 2024-03-21 00:12:17 +01:00
Carlos Valente 93622e9aeb fix: prevent calculation with no timer (#835) 2024-03-20 12:52:32 +01:00
Carlos Valente 1568af97fc chore: bump node version in docker (#834) 2024-03-19 21:55:37 +01:00
Carlos Valente 4bb836b6f2 End pause (#832)
* refactor: trigger ahead

* refactor: refresh often

* fix: handle empty rundown updates

* fix: handle falsy values

* style: avoid overlap of components

* refactor: freeze end option
2024-03-19 18:04:05 +01:00
Carlos Valente 8156da03d5 Quick options (#814)
* refactor: link start is quick option

* refactor: add default duration

* chore: use last time by default
2024-03-19 17:23:32 +01:00
Carlos Valente dfade25a7c feat: url navigation for settings (#821) 2024-03-18 20:36:02 +01:00
Carlos Valente 4b21745fc7 refactor: download files from interface (#831) 2024-03-18 20:27:58 +01:00
Carlos Valente a91a8a6358 refactor: simplify time-to-end (#828)
* refactor: simplify time-to-end

* refactor: rundown metadata

* refactor: handle overflow in UI

* refactor: show gaps between days

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
2024-03-18 17:24:11 +01:00
Carlos Valente 4e6b833e10 refactor: obfuscated pincode in transit 2024-03-18 13:40:59 +01:00
Alex Christoffer Rasmussen 3fdb1bd1c8 partial ws update for demo (#813)
* partial ws update

* extract more of the timer formatting
2024-03-18 12:01:36 +01:00
Carlos Valente 4148a3835b style: clarify state of external message 2024-03-17 19:46:48 +01:00
Carlos Valente 4fb9c42aef refactor: skip expected end if not playing 2024-03-17 19:46:25 +01:00
Carlos Valente dd0dcfc2a2 fix: reset focus when deleting selected 2024-03-17 19:46:10 +01:00
Carlos Valente d41d1b054d fix: reset focus when deleting all 2024-03-17 19:46:10 +01:00
Carlos Valente 8b6e06150a fix: update cache on delete-all (#827) 2024-03-17 19:45:34 +01:00
Carlos Valente a9e0e2b091 refactor: tweaks to chakra theme config (#824) 2024-03-16 09:42:06 +01:00
Carlos Valente 0a8155b6e3 Param nav (#822)
* refactor: remove unused

* refactor: simplify logic
2024-03-15 16:52:33 +01:00
Carlos Valente 118c29e5c2 refactor: filter sentry errors (#816) 2024-03-15 16:13:26 +01:00
Carlos Valente 105f395e6e refactor: migrate fullscreen to mantine (#819) 2024-03-14 18:32:07 +01:00
117 changed files with 1807 additions and 879 deletions
+3
View File
@@ -45,3 +45,6 @@ apps/server/src/preloaded-db/db.json
# versioning file
**/ONTIME_VERSION.js
# temporary write files
**.tmp
+1 -1
View File
@@ -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
+1
View File
@@ -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",
+1 -2
View File
@@ -47,9 +47,8 @@ function App() {
}
};
}, [isElectron, sendToElectron]);
return (
<ChakraProvider resetCSS theme={theme}>
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<BrowserRouter>
+53 -34
View File
@@ -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 };
}
+42
View File
@@ -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;
}
+10 -2
View File
@@ -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
*/
+13 -48
View File
@@ -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 };
}
+21 -18
View File
@@ -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 -2
View File
@@ -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'
@@ -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>
</>
@@ -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'>
+10 -8
View File
@@ -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}>
+22 -3
View File
@@ -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>
);
}
+9 -10
View File
@@ -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'
+1 -1
View File
@@ -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],
});
+2
View File
@@ -31,6 +31,8 @@ import {
import { ontimeTooltip } from './ontimeTooltip';
const theme = extendTheme({
initialColorMode: 'dark',
useSystemColorMode: false,
components: {
Alert: {
variants: {
+1 -1
View File
@@ -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",
+18 -58
View File
@@ -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 -14
View File
@@ -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');
+5 -9
View File
@@ -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();
},
];
+2
View File
@@ -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);
+1 -5
View File
@@ -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',
+1
View File
@@ -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
};
+30 -14
View File
@@ -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;
}
}
};
+1
View File
@@ -25,6 +25,7 @@ export const dbModel: DatabaseModel = {
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
dangerColor: '#ED3333',
freezeEnd: false,
endMessage: '',
},
urlPresets: [],
+13 -11
View File
@@ -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;
}
}
+28 -5
View File
@@ -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({});
+36 -10
View File
@@ -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;
}
+9 -2
View File
@@ -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();
+30 -28
View File
@@ -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;
+64 -20
View File
@@ -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);
});
});
+1 -1
View File
@@ -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