Compare commits

...

19 Commits

Author SHA1 Message Date
Carlos Valente 71092afc02 ontime release (#694)
* chore: version bump

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2024-01-10 20:50:19 +01:00
Alex Christoffer Rasmussen b1ba47a80f Fix/external path (#702)
* leave styles as is but move other external to `external` folder
2024-01-09 22:57:06 +01:00
Alex Christoffer Rasmussen 9611722a6d fix: use background-color: transparent; for overlap indicators (#699) 2024-01-09 16:42:50 +01:00
Alex Christoffer Rasmussen 92cefa1331 refactor: errors from integrationcontroller (#700)
* refactor: these functions don't throw, test the return success and then throw

* refactor: no need to catch and re-throw
2024-01-09 16:01:17 +01:00
Alex Christoffer Rasmussen c0fb865959 feat: google sheets(#579)
---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-01-08 15:51:58 +01:00
Oliver Herrmann 068ec800f4 add localdocker build commands (#638) 2024-01-05 21:04:52 +01:00
Alex Christoffer Rasmussen 1a8b35a5ea feat: show overlap (#633)
* feat: show spacing or overlap
2024-01-05 21:04:16 +01:00
Fabian Posenau 40bede200c nightly builds hopefully (#681)
Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-12-30 10:04:42 +01:00
Fabian Posenau f3e2944c55 fix docker compose ontime version (#680)
Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-12-29 22:25:41 +01:00
Fabian Posenau 09f2874784 Fix Visual bug on rundown (#678)
* fix: bug with draw order on past rundown items

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>
2023-12-29 20:18:05 +01:00
Carlos Valente 8afffb9870 style: fix missing overrides (#666)
* style: fix missing overrides

* style: fix recover idle colour
2023-12-22 21:05:23 +01:00
Carlos Valente 33575c551a refactor: improve typing on transform (#626)
* refactor: improve typing on transform

* chore: use placeholder for data errors
2023-12-19 10:03:25 +01:00
Carlos Valente 3173fb57fc chore: add italian to languages (#656) 2023-12-18 15:07:48 +01:00
Carlos Valente 7d33019f53 refactor: prevent accidental edit in operator view (#659)
* refactor: prevent accidental edit in operator view
2023-12-18 15:07:30 +01:00
Matteo Gheza 6526b32d4c Add italian client translation (#655)
* Add Italian client translation
2023-12-17 20:59:05 +01:00
Carlos Valente d6aef5ea31 fix: correct link for CSS overrides (#650) 2023-12-14 20:14:09 +01:00
Alex Christoffer Rasmussen 8756b396cc feat: minimal demo page (#644)
* minimal

* eslint-browser

* dynamic port, reconnect socket, msTo string without Date

* 404 on external not found
2023-12-14 13:27:18 +01:00
Carlos Valente 5a2f711eab refactor: cleanup sentry errors (#653) 2023-12-13 22:12:54 +01:00
Carlos Valente a56e2b1c1c fix: link for mac download (#648) 2023-12-13 19:22:17 +01:00
53 changed files with 2580 additions and 316 deletions
+13 -1
View File
@@ -44,7 +44,8 @@ jobs:
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Build and push Docker images
- name: Build and push stable release
if: github.event.release.prerelease == false
uses: docker/build-push-action@v4.0.0
with:
context: .
@@ -54,3 +55,14 @@ jobs:
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
+1
View File
@@ -89,6 +89,7 @@ While it should allow for a generic setup, it might need to be modified to fit y
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build packages__ by running `pnpm build:localdocker`
- __Build docker image from__ by running `docker build -t getontime/ontime`
- __Run docker image from compose__ by running `docker-compose up -d`
+1 -1
View File
@@ -4,7 +4,7 @@
## Download the latest releases here
<div style="display: flex; justify-content: space-around">
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS.dmg"><img alt="Download MacOS" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/mac-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg"><img alt="Download MacOS" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/mac-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe"><img alt="Download Windows" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/win-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage"><img alt="Download Linux" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/linux-download.png"/></a>
<a href="https://hub.docker.com/r/getontime/ontime"><img alt="Get from Dockerhub" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/dockerhub.png"/></a>
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "2.24.8",
"version": "2.28.11",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.7.0",
@@ -40,6 +40,7 @@
"build:local": "cross-env NODE_ENV=local vite build",
"build:electron": "cross-env NODE_ENV=local vite build",
"build:docker": "vite build",
"build:localdocker": "cross-env NODE_ENV=local vite build",
"lint": "eslint . --quiet",
"lint-staged": "eslint",
"test": "vitest",
@@ -9,6 +9,7 @@ export const HTTP_SETTINGS = ['httpSettings'];
export const APP_SETTINGS = ['appSettings'];
export const VIEW_SETTINGS = ['viewSettings'];
export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState'];
const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
+73
View File
@@ -245,3 +245,76 @@ export async function getLatestVersion(): Promise<HasUpdate> {
export async function postNew(initialData: Partial<ProjectData>) {
return axios.post(`${ontimeURL}/new`, initialData);
}
/**
* @description STEP 1
*/
export const uploadSheetClientFile = async (file: File) => {
const formData = new FormData();
formData.append('userFile', file);
const res = await axios
.post(`${ontimeURL}/sheet/clientsecret`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then((response) => response.data.id);
return res;
};
/**
* @description STEP 1 test
*/
export const getClientSecrect = async () => {
const response = await axios.get(`${ontimeURL}/sheet/clientsecret`);
return response.data;
};
/**
* @description STEP 2
*/
export const getSheetsAuthUrl = async () => {
const response = await axios.get(`${ontimeURL}/sheet/authentication/url`);
return response.data;
};
/**
* @description STEP 2 test
*/
export const getAuthentication = async () => {
const response = await axios.get(`${ontimeURL}/sheet/authentication`);
return response.data;
};
/**
* @description STEP 3
* @returns worksheetOptions
*/
export const postId = async (id: string) => {
const response = await axios.post(`${ontimeURL}/sheet/id`, { id });
return response.data;
};
/**
* @description STEP 4
*/
export const postWorksheet = async (id: string, worksheet: string) => {
const response = await axios.post(`${ontimeURL}/sheet/worksheet`, { id, worksheet });
return response.data;
};
/**
* @description STEP 5
*/
export const postPreviewSheet = async (id: string, options: ExcelImportMap) => {
const response = await axios.post(`${ontimeURL}/sheet/pull`, { id, options });
return response.data.data;
};
/**
* @description STEP 5
*/
export const postPushSheet = async (id: string, options: ExcelImportMap) => {
const response = await axios.post(`${ontimeURL}/sheet-push`, { id, options });
return response.data.data;
};
@@ -17,9 +17,8 @@ interface TimeInputProps {
time?: number;
delay?: number;
placeholder: string;
validationHandler: (entry: TimeEntryField, val: number) => boolean;
previousEnd?: number;
warning?: string;
tooltip?: string;
}
function ButtonInitial(name: TimeEntryField) {
@@ -29,25 +28,15 @@ function ButtonInitial(name: TimeEntryField) {
return '';
}
function ButtonTooltip(name: TimeEntryField, warning?: string) {
if (name === 'timeStart') return `Start${warning ? `: ${warning}` : ''}`;
if (name === 'timeEnd') return `End${warning ? `: ${warning}` : ''}`;
if (name === 'durationOverride') return `Duration${warning ? `: ${warning}` : ''}`;
function ButtonTooltip(name: TimeEntryField, tooltip?: string) {
if (name === 'timeStart') return `Start${tooltip ? `: ${tooltip}` : ''}`;
if (name === 'timeEnd') return `End${tooltip ? `: ${tooltip}` : ''}`;
if (name === 'durationOverride') return `Duration${tooltip ? `: ${tooltip}` : ''}`;
return '';
}
export default function TimeInput(props: TimeInputProps) {
const {
id,
name,
submitHandler,
time = 0,
delay = 0,
placeholder,
validationHandler,
previousEnd = 0,
warning,
} = props;
const { id, name, submitHandler, time = 0, delay = 0, placeholder, previousEnd = 0 } = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
@@ -103,15 +92,12 @@ export default function TimeInput(props: TimeInputProps) {
// check if time is different from before
if (newValMillis === time) return false;
// validate with parent
if (!validationHandler(name, newValMillis)) return false;
// update entry
submitHandler(name, newValMillis);
return true;
},
[name, previousEnd, submitHandler, time, validationHandler],
[name, previousEnd, submitHandler, time],
);
/**
@@ -171,11 +157,11 @@ export default function TimeInput(props: TimeInputProps) {
const isDelayed = delay !== 0;
const inputClasses = cx([style.timeInput, isDelayed ? style.delayed : null]);
const buttonClasses = cx([style.inputButton, isDelayed ? style.delayed : null, warning ? style.warn : null]);
const buttonClasses = cx([style.inputButton, isDelayed ? style.delayed : null]);
const TooltipLabel = useMemo(() => {
return ButtonTooltip(name, warning);
}, [name, warning]);
return ButtonTooltip(name, '');
}, [name]);
const ButtonText = useMemo(() => {
return ButtonInitial(name);
@@ -380,5 +380,12 @@ export const getOperatorOptions = (userFields: UserFields, timeFormat: TimeForma
user9: userFields.user9 || 'user9',
},
},
{
id: 'shouldEdit',
title: 'Edit user field',
description: 'Allows editing an events user field by long pressing on it. Needs a selected highlighted field',
type: 'boolean',
defaultValue: false,
},
];
};
@@ -1,7 +1,4 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-nocheck -- working on it
import { useMutation, useQuery } from '@tanstack/react-query';
import { OSCSettings } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { OSC_SETTINGS } from '../api/apiConstants';
@@ -13,7 +10,10 @@ import { ontimeQueryClient } from '../queryClient';
export default function useOscSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: OSC_SETTINGS,
queryFn: getOSC,
queryFn: async () => {
const oscData = await getOSC();
return { ...oscData, portIn: String(oscData.portIn), portOut: String(oscData.portOut) };
},
placeholderData: oscPlaceholderSettings,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
@@ -21,8 +21,7 @@ export default function useOscSettings() {
networkMode: 'always',
});
// we need to jump through some hoops because of the type op port
return { data: data! as unknown as OSCSettings, status, isFetching, isError, refetch };
return { data: data ?? oscPlaceholderSettings, status, isFetching, isError, refetch };
}
export function useOscSettingsMutation() {
@@ -1,44 +1 @@
export type TimeEntryField = 'timeStart' | 'timeEnd' | 'durationOverride';
/**
* @description Checks which field the value relates to
*/
export const handleTimeEntry = (
field: TimeEntryField,
val: number,
timeStart: number,
timeEnd: number,
): { start: number; end: number; durationOverride: boolean } => {
let start = timeStart;
let end = timeEnd;
let durationOverride = false;
if (field === 'timeStart') {
start = val;
} else if (field === 'timeEnd') {
end = val;
} else {
durationOverride = field === 'durationOverride';
}
return { start, end, durationOverride };
};
/**
* @description Validates time entry
*/
export const validateEntry = (
field: TimeEntryField,
value: number,
timeStart: number,
timeEnd: number,
): { value: boolean; warnings: { start?: string; end?: string; duration?: string } } => {
const validate = { value: true, warnings: { start: '', end: '', duration: '' } };
const { start, end } = handleTimeEntry(field, value, timeStart, timeEnd);
if (end < start) {
validate.warnings.start = 'Start time later than end time';
}
return validate;
};
@@ -5,6 +5,7 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
import MenuBar from '../menu/MenuBar';
import AboutModal from '../modals/about-modal/AboutModal';
import QuickStart from '../modals/quick-start/QuickStart';
import SheetsModal from '../modals/sheets-modal/SheetsModal';
import UploadModal from '../modals/upload-modal/UploadModal';
import styles from './Editor.module.scss';
@@ -28,6 +29,7 @@ export default function Editor() {
} = useDisclosure();
const { isOpen: isAboutModalOpen, onOpen: onAboutModalOpen, onClose: onAboutModalClose } = useDisclosure();
const { isOpen: isQuickStartOpen, onOpen: onQuickStartOpen, onClose: onQuickStartClose } = useDisclosure();
const { isOpen: isSheetsOpen, onOpen: onSheetsOpen, onClose: onSheetsClose } = useDisclosure();
// Set window title
useEffect(() => {
@@ -42,6 +44,7 @@ export default function Editor() {
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
<AboutModal onClose={onAboutModalClose} isOpen={isAboutModalOpen} />
<SettingsModal isOpen={isSettingsOpen} onClose={onSettingsClose} />
<SheetsModal onClose={onSheetsClose} isOpen={isSheetsOpen} />
</ErrorBoundary>
<div className={styles.mainContainer} data-testid='event-editor'>
<div id='settings' className={styles.settings}>
@@ -58,6 +61,8 @@ export default function Editor() {
onAboutOpen={onAboutModalOpen}
isQuickStartOpen={isQuickStartOpen}
onQuickStartOpen={onQuickStartOpen}
isSheetsOpen={isSheetsOpen}
onSheetsOpen={onSheetsOpen}
/>
</ErrorBoundary>
</div>
@@ -1,4 +1,4 @@
import { memo, useState } from 'react';
import { memo } from 'react';
import { Select, Switch } from '@chakra-ui/react';
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
import { calculateDuration, millisToString } from 'ontime-utils';
@@ -7,7 +7,6 @@ import TimeInput from '../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { millisToDelayString } from '../../../common/utils/dateConfig';
import { cx } from '../../../common/utils/styleUtils';
import { TimeEntryField, validateEntry } from '../../../common/utils/timesManager';
import style from '../EventEditor.module.scss';
@@ -29,13 +28,6 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
const { eventId, timeStart, timeEnd, duration, delay, isPublic, endAction, timerType } = props;
const { updateEvent } = useEventAction();
const [warning, setWarnings] = useState({ start: '', end: '', duration: '' });
const timerValidationHandler = (entry: TimeEntryField, val: number) => {
const valid = validateEntry(entry, val, timeStart, timeEnd);
setWarnings((prev) => ({ ...prev, ...valid.warnings }));
return valid.value;
};
const handleSubmit = (field: TimeActions, value: number | string | boolean) => {
const newEventData: Partial<OntimeEvent> = { id: eventId };
@@ -87,11 +79,9 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
id='timeStart'
name='timeStart'
submitHandler={handleSubmit}
validationHandler={timerValidationHandler}
time={timeStart}
delay={delay}
placeholder='Start'
warning={warning.start}
/>
<label className={inputTimeLabels} htmlFor='timeEnd'>
{endLabel}
@@ -100,11 +90,9 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
id='timeEnd'
name='timeEnd'
submitHandler={handleSubmit}
validationHandler={timerValidationHandler}
time={timeEnd}
delay={delay}
placeholder='End'
warning={warning.end}
/>
<label className={style.inputLabel} htmlFor='durationOverride'>
Duration
@@ -113,10 +101,8 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
id='durationOverride'
name='durationOverride'
submitHandler={handleSubmit}
validationHandler={timerValidationHandler}
time={duration}
placeholder='Duration'
warning={warning.duration}
/>
</div>
<div className={style.timeSettings}>
+16
View File
@@ -1,5 +1,7 @@
import { memo, useCallback, useEffect, useState } from 'react';
import { VStack } from '@chakra-ui/react';
import { IoCloud } from '@react-icons/all-files/io5/IoCloud';
import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand';
import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle';
import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline';
@@ -31,6 +33,8 @@ interface MenuBarProps {
onAboutOpen: () => void;
isQuickStartOpen: boolean;
onQuickStartOpen: () => void;
isSheetsOpen: boolean;
onSheetsOpen: () => void;
}
const buttonStyle = {
@@ -58,6 +62,8 @@ const MenuBar = (props: MenuBarProps) => {
onAboutOpen,
isQuickStartOpen,
onQuickStartOpen,
isSheetsOpen,
onSheetsOpen,
} = props;
const { isElectron, sendToElectron } = useElectronEvent();
@@ -174,6 +180,16 @@ const MenuBar = (props: MenuBarProps) => {
/>
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
isDisabled={appMode === AppMode.Run}
icon={isSheetsOpen ? <IoCloud /> : <IoCloudOutline />}
className={isSheetsOpen ? style.open : ''}
clickHandler={onSheetsOpen}
tooltip='Sheets'
aria-label='Sheets'
size='sm'
/>
<TooltipActionBtn
{...buttonStyle}
isDisabled={appMode === AppMode.Run}
@@ -1,5 +1,3 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-nocheck -- working on it
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { FormControl, Input, Switch } from '@chakra-ui/react';
@@ -59,7 +57,6 @@ export default function OscSettings() {
};
const resetForm = () => {
// @ts-expect-error -- we know the types dont match
reset(data);
};
@@ -119,6 +119,7 @@ export default function AppSettingsModal() {
<option value='en'>English</option>
<option value='fr'>French</option>
<option value='de'>German</option>
<option value='it'>Italian</option>
<option value='no'>Norwegian</option>
<option value='pt'>Portuguese</option>
<option value='es'>Spanish</option>
@@ -0,0 +1,469 @@
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Button,
HStack,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Select,
} from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils';
import { PROJECT_DATA, RUNDOWN, USERFIELDS } from '../../../common/api/apiConstants';
import { maybeAxiosError } from '../../../common/api/apiUtils';
import {
getAuthentication,
getClientSecrect,
getSheetsAuthUrl,
patchData,
postId,
postPreviewSheet,
postPushSheet,
postWorksheet,
uploadSheetClientFile,
} from '../../../common/api/ontimeApi';
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
import { userFieldsPlaceholder } from '../../../common/models/UserFields';
import { openLink } from '../../../common/utils/linkUtils';
import ModalLink from '../ModalLink';
import PreviewExcel from '../upload-modal/preview/PreviewExcel';
import ExcelFileOptions from '../upload-modal/upload-options/ExcelFileOptions';
import Step from './Step';
interface SheetsModalProps {
onClose: () => void;
isOpen: boolean;
}
export default function SheetsModal(props: SheetsModalProps) {
const { isOpen, onClose } = props;
const queryClient = useQueryClient();
const [rundown, setRundown] = useState<OntimeRundown | null>(null);
const [userFields, setUserFields] = useState<UserFields | null>(null);
const [project, setProject] = useState<ProjectData | null>(null);
const [id, setSheetId] = useState('');
const [worksheet, setWorksheet] = useState('');
const [worksheetOptions, setWorksheetOptions] = useState<string[]>([]);
const [direction, setDirection] = useState('none');
const excelFileOptions = useRef<ExcelImportMap>(defaultExcelImportMap);
const fileInputRef = useRef<HTMLInputElement>(null);
const [state, setState] = useState({
clientSecret: { complete: false, message: '' },
authenticate: { complete: false, message: '' },
id: { complete: false, message: '' },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
useEffect(() => {
if (isOpen) {
setDirection('none');
testClientSecret();
if (state.clientSecret.complete) testAuthentication();
if (state.authenticate.complete) testSheetId();
}
}, [isOpen]);
const handleClose = () => {
setRundown(null);
setProject(null);
setUserFields(null);
onClose();
};
//SETP-1 Upload Client ID
const handleClick = () => {
fileInputRef.current?.click();
};
const handleFile = (event: ChangeEvent<HTMLInputElement>) => {
if (!event.target.files?.length) {
setState({
clientSecret: { complete: false, message: 'Missing file' },
authenticate: { complete: false, message: '' },
id: { complete: false, message: '' },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
return;
}
const selectedFile = event.target.files[0];
uploadSheetClientFile(selectedFile)
.then(() => {
setState({ ...state, clientSecret: { complete: true, message: '' } });
})
.catch((err) => {
const message = maybeAxiosError(err);
setState({
clientSecret: { complete: false, message },
authenticate: { complete: false, message: '' },
id: { complete: false, message: '' },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
});
};
const testClientSecret = () => {
getClientSecrect()
.then(() => {
setState({ ...state, clientSecret: { complete: true, message: '' } });
})
.catch((err) => {
const message = maybeAxiosError(err);
setState({
clientSecret: { complete: false, message },
authenticate: { complete: false, message: '' },
id: { complete: false, message: '' },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
});
};
//SETP-2 Authenticate
const handleAuthenticate = () => {
getSheetsAuthUrl()
.then((data) => {
openLink(data);
window.addEventListener('focus', () => testAuthentication(), { once: true });
})
.catch((err) => {
const message = maybeAxiosError(err);
setState({
...state,
authenticate: { complete: false, message },
id: { complete: false, message: '' },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
});
};
const testAuthentication = () => {
getAuthentication()
.then(() => {
setState({ ...state, authenticate: { complete: true, message: '' } });
})
.catch((error) => {
const message = maybeAxiosError(error);
setState({
...state,
authenticate: { complete: false, message },
id: { complete: false, message: '' },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
});
};
//SETP-3 set sheet ID
const testSheetId = () => {
postId(id)
.then((data) => {
setState({ ...state, id: { complete: true, message: '' } });
setWorksheetOptions(data.worksheetOptions);
})
.catch((err) => {
const message = maybeAxiosError(err);
setState({
...state,
id: { complete: false, message },
worksheet: { complete: false, message: '' },
pullPush: { complete: false, message: '' },
});
setWorksheetOptions([]);
});
};
//SETP-4 Select Worksheet
const testWorksheet = (value: string) => {
excelFileOptions.current.worksheet = value;
setWorksheet(value);
postWorksheet(id, worksheet)
.then(() => {
setState({ ...state, worksheet: { complete: true, message: '' } });
})
.catch((err) => {
const message = maybeAxiosError(err);
setState({ ...state, worksheet: { complete: false, message }, pullPush: { complete: false, message: '' } });
});
};
//SETP-5 Upload / Download
const updateExcelFileOptions = <T extends keyof ExcelImportMap>(field: T, value: ExcelImportMap[T]) => {
if (excelFileOptions.current[field] !== value) {
excelFileOptions.current = { ...excelFileOptions.current, [field]: value };
}
};
const handlePullData = () => {
postPreviewSheet(id, excelFileOptions.current)
.then((data) => {
setProject(data.project);
setRundown(data.rundown);
setUserFields(data.userFields);
})
.catch((error) => {
const message = maybeAxiosError(error);
setDirection('none');
setState({ ...state, pullPush: { complete: false, message } });
});
};
const handlePushData = () => {
postPushSheet(id, excelFileOptions.current)
.then(() => {
setDirection('none');
setState({ ...state, pullPush: { complete: true, message: '' } });
})
.catch((error) => {
const message = maybeAxiosError(error);
setDirection('none');
setState({ ...state, pullPush: { complete: false, message } });
});
};
//GET preview
const handleFinalise = async () => {
if (rundown && userFields && project) {
let doClose = false;
try {
await patchData({ rundown, userFields, project });
queryClient.setQueryData(RUNDOWN, rundown);
queryClient.setQueryData(USERFIELDS, userFields);
queryClient.setQueryData(PROJECT_DATA, project);
await queryClient.invalidateQueries({
queryKey: [...RUNDOWN, ...USERFIELDS, ...PROJECT_DATA],
});
doClose = true;
} catch (error) {
const message = maybeAxiosError(error);
console.error(message);
} finally {
if (doClose) {
handleClose();
}
}
}
};
return (
<Modal
onClose={handleClose}
isOpen={isOpen}
closeOnOverlayClick={false}
motionPreset='slideInBottom'
size='xl'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime-upload'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>Rundown from sheets (experimental)</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Alert status='info' variant='ontime-on-light-info'>
<AlertIcon />
<div style={{ display: 'flex', flexDirection: 'column' }}>
<AlertTitle>Sync with Google Sheets</AlertTitle>
<AlertDescription>
<ModalLink href='https://ontime.gitbook.io/v2/features/google-sheet'>
For more information, see the docs
</ModalLink>
</AlertDescription>
</div>
</Alert>
{!rundown ? (
direction === 'up' || direction === 'down' ? (
<ExcelFileOptions optionsRef={excelFileOptions} updateOptions={updateExcelFileOptions} />
) : (
<>
<Step
title='1 - Upload OAuth 2.0 Client ID'
completed={state.clientSecret.complete}
disabled={false}
error={state.clientSecret.message}
>
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleFile}
accept='.json'
data-testid='file-input'
/>
<div style={{ display: 'flex', gap: '1em' }}>
<Button size='sm' variant='ontime-subtle-on-light' onClick={handleClick}>
{state.clientSecret.complete ? 'Reupload Client ID' : 'Upload Client ID'}
</Button>
<Button size='sm' variant='ontime-ghosted-on-light' onClick={testClientSecret}>
Retry Client ID
</Button>
</div>
</Step>
<Step
title='2 - Authenticate with Google'
completed={state.authenticate.complete}
disabled={!state.clientSecret.complete}
error={state.authenticate.message}
>
<div style={{ display: 'flex', gap: '1em' }}>
<Button
size='sm'
variant='ontime-subtle-on-light'
onClick={handleAuthenticate}
isDisabled={!state.clientSecret.complete}
>
Authenticate
</Button>
<Button
size='sm'
variant='ontime-ghosted-on-light'
onClick={testAuthentication}
isDisabled={!state.clientSecret.complete}
>
Retry Connection
</Button>
</div>
</Step>
<Step
title='3 - Add Document ID'
completed={state.id.complete}
disabled={!state.authenticate.complete}
error={state.id.message}
>
<HStack>
<Input
type='text'
size='sm'
variant='ontime-filled-on-light'
isDisabled={!state.authenticate.complete}
value={id}
onChange={(event) => setSheetId(event.target.value)}
/>
<Button
isDisabled={!state.authenticate.complete}
size='sm'
variant='ontime-subtle-on-light'
padding='0 2em'
onClick={testSheetId}
>
Connect
</Button>
</HStack>
</Step>
<Step
title='4 - Select Worksheet to import'
completed={state.worksheet.complete}
disabled={worksheetOptions.length == 0}
>
<Select
size='sm'
isDisabled={worksheetOptions.length == 0}
placeholder='Select a worksheet'
onChange={(event) => testWorksheet(event.target.value)}
value={worksheet}
>
{worksheetOptions.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</Select>
</Step>
<Step title='5 - Upload / Download rundown' completed={false} disabled={!state.worksheet.complete}>
<div style={{ display: 'flex', gap: '1em' }}>
<Button
isDisabled={!state.worksheet.complete}
variant='ontime-subtle-on-light'
padding='0 2em'
onClick={() => setDirection('up')}
>
Upload
</Button>
<Button
isDisabled={!state.worksheet.complete}
variant='ontime-subtle-on-light'
padding='0 2em'
onClick={() => setDirection('down')}
>
Download
</Button>
</div>
</Step>
</>
)
) : (
<PreviewExcel
rundown={rundown ?? []}
project={project ?? projectDataPlaceholder}
userFields={userFields ?? userFieldsPlaceholder}
/>
)}
</ModalBody>
<ModalFooter>
{rundown ? (
<div style={{ display: 'flex', gap: '1em' }}>
<Button
onClick={() => {
setRundown(null);
setDirection('none');
}}
variant='ontime-ghost-on-light'
>
Go Back
</Button>
<Button variant='ontime-filled' padding='0 2em' onClick={handleFinalise}>
Import
</Button>
</div>
) : direction === 'up' ? (
<div style={{ display: 'flex', gap: '1em' }}>
<Button onClick={() => setDirection('none')} variant='ontime-ghost-on-light'>
Go Back
</Button>
<Button variant='ontime-filled' padding='0 2em' onClick={handlePushData}>
Upload
</Button>
</div>
) : direction === 'down' ? (
<div style={{ display: 'flex', gap: '1em' }}>
<Button onClick={() => setDirection('none')} variant='ontime-ghost-on-light'>
Go Back
</Button>
<Button variant='ontime-filled' padding='0 2em' onClick={handlePullData}>
Preview
</Button>
</div>
) : (
<></>
)}
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,42 @@
@use '../../../theme/ontimeColours' as *;
.wrapper {
padding: 0.5rem;
}
.header {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
padding-bottom: 1rem;
}
.step {
width: 1.25rem;
height: 1.25rem;
font-size: 0.75rem;
display: grid;
place-content: center;
}
.title {
display: inline;
color: $gray-500;
text-transform: uppercase;
cursor: pointer;
}
.check {
color: $green-700;
}
.errorIcon {
color: $red-700;
}
.errorText {
color: $red-700;
font-size: 0.75rem;
}
@@ -0,0 +1,47 @@
import { PropsWithChildren, useEffect, useMemo, useState } from 'react';
import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle';
import { IoCloseCircle } from '@react-icons/all-files/io5/IoCloseCircle';
import { IoRadioButtonOffOutline } from '@react-icons/all-files/io5/IoRadioButtonOffOutline';
import style from './Step.module.scss';
interface StepProps {
title: string;
disabled: boolean;
completed: boolean;
error?: string;
}
export default function Step(props: PropsWithChildren<StepProps>) {
const { title, disabled, completed, error, children } = props;
const [collapsed, setCollapsed] = useState(disabled);
const handleCollapse = () => setCollapsed((prev) => !prev);
const icon = useMemo(() => {
if (completed) return <IoCheckmarkCircle className={style.step} style={{ color: 'green' }} />;
if (error) return <IoCloseCircle className={style.step} style={{ color: 'red' }} />;
return <IoRadioButtonOffOutline className={style.step} />;
}, [completed, error]);
useEffect(() => {
if (completed) {
setCollapsed(true);
}
}, [completed]);
return (
<div className={style.wrapper}>
<div className={style.header} onClick={handleCollapse}>
{icon}
<span className={style.title}>{title}</span>
</div>
{!collapsed && (
<>
{error && <div className={style.errorText}>{error}</div>}
{children}
</>
)}
</div>
);
}
@@ -123,7 +123,10 @@ export default function Operator() {
}
// get fields which the user subscribed to
const shouldEdit = searchParams.get('shouldEdit');
const subscribe = searchParams.get('subscribe') as keyof UserFields | null;
const canEdit = shouldEdit && subscribe;
const main = searchParams.get('main') as keyof TitleFields | null;
const secondary = searchParams.get('secondary') as keyof TitleFields | null;
const subscribedAlias = subscribe ? userFields[subscribe] : '';
@@ -152,7 +155,7 @@ export default function Operator() {
lastId={lastEvent?.id}
/>
{subscribe && (
{canEdit && (
<div className={`${style.editPrompt} ${showEditPrompt ? style.show : undefined}`}>
Press and hold to edit user field
</div>
@@ -193,7 +196,7 @@ export default function Operator() {
showSeconds={showSeconds}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
onLongPress={subscribe ? handleEdit : () => undefined}
onLongPress={canEdit ? handleEdit : () => undefined}
/>
);
}
@@ -198,7 +198,9 @@ export default function Rundown(props: RundownProps) {
if (index === 0) {
eventIndex = 0;
}
let isFirstEvent = false;
if (entry.type === SupportedEvent.Event) {
isFirstEvent = eventIndex === 0;
eventIndex++;
previousEnd = thisEnd;
thisEnd = entry.timeEnd;
@@ -220,6 +222,7 @@ export default function Rundown(props: RundownProps) {
<RundownEntry
type={entry.type}
isPast={isPast}
isFirstEvent={isFirstEvent}
data={entry}
selected={isSelected}
hasCursor={hasCursor}
@@ -19,6 +19,7 @@ export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'del
interface RundownEntryProps {
type: SupportedEvent;
isPast: boolean;
isFirstEvent: boolean;
data: OntimeRundownEntry;
selected: boolean;
hasCursor: boolean;
@@ -31,8 +32,19 @@ interface RundownEntryProps {
}
export default function RundownEntry(props: RundownEntryProps) {
const { isPast, data, selected, hasCursor, next, previousEnd, previousEventId, playback, isRolling, disableEdit } =
props;
const {
isPast,
data,
selected,
hasCursor,
next,
previousEnd,
previousEventId,
playback,
isRolling,
disableEdit,
isFirstEvent,
} = props;
const { emitError } = useEmitLog();
const { addEvent, updateEvent, deleteEvent, swapEvents } = useEventAction();
@@ -100,7 +112,7 @@ export default function RundownEntry(props: RundownEntryProps) {
}
case 'clone': {
const newEvent = cloneEvent(data as OntimeEvent, data.id);
const rundown = ontimeQueryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? []
const rundown = ontimeQueryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? [];
newEvent.cue = getCueCandidate(rundown, data.id);
addEvent(newEvent);
break;
@@ -176,6 +188,7 @@ export default function RundownEntry(props: RundownEntryProps) {
isRolling={isRolling}
actionHandler={actionHandler}
disableEdit={disableEdit}
isFirstEvent={isFirstEvent}
/>
);
} else if (data.type === SupportedEvent.Block) {
@@ -10,11 +10,11 @@ $skip-opacity: 0.1;
display: grid;
grid-template-areas:
"binder ... ... ..."
"binder pb-actions times actions"
"binder pb-actions title next"
"binder pb-actions estatus estatus"
"binder ... ... ...";
'binder ... ... ...'
'binder pb-actions times actions'
'binder pb-actions title next-ind'
'binder pb-actions estatus estatus'
'binder ... ... ...';
grid-template-columns: $block-binder-width auto 1fr auto;
grid-template-rows: 0.25rem 2.25rem 2.25rem 2.25rem 0.25rem;
@@ -53,8 +53,18 @@ $skip-opacity: 0.1;
outline: 1px solid $block-cursor-color;
}
/* we stop the eventActions from having opacity to fix issue with dropdown drawing order */
&.past:not(.skip) {
opacity: 0.6;
.delayNote,
.statusElements,
.eventTitle,
.eventNote,
.eventTimers,
.eventStatus,
.playbackActions,
.binder {
opacity: 0.4;
}
}
&.skip {
@@ -160,8 +170,8 @@ $skip-opacity: 0.1;
grid-area: estatus;
display: grid;
grid-template-areas:
"notes status"
"progb progb";
'notes status'
'progb progb';
gap: 2px;
grid-template-rows: auto 0.25rem;
align-items: center;
@@ -180,9 +190,8 @@ $skip-opacity: 0.1;
overflow-y: hidden;
}
.nextTag {
grid-area: next;
grid-area: next-ind;
font-size: 1rem;
color: $orange-500;
letter-spacing: 0.03px;
@@ -190,6 +199,31 @@ $skip-opacity: 0.1;
text-align: right;
}
.indicators {
grid-area: next-ind;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.5rem;
.indicator {
background-color: transparent;
margin: 0.4rem;
margin-right: 0;
border-radius: 0.7rem;
width: 0.7rem;
height: 0.7rem;
}
.indicator.delay {
background-color: $ontime-delay;
}
.indicator.nextDay,
.indicator.overlap,
.indicator.spacing {
background-color: $gray-600;
}
}
.eventStatus {
grid-area: status;
display: flex;
@@ -198,7 +232,6 @@ $skip-opacity: 0.1;
gap: 0.5rem;
color: var(--status-color-override, $gray-500);
.statusIcon {
width: 1rem;
height: 1rem;
@@ -50,6 +50,7 @@ interface EventBlockProps {
},
) => void;
disableEdit: boolean;
isFirstEvent: boolean;
}
export default function EventBlock(props: EventBlockProps) {
@@ -76,6 +77,7 @@ export default function EventBlock(props: EventBlockProps) {
isRolling,
actionHandler,
disableEdit,
isFirstEvent,
} = props;
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
const moveCursorTo = useAppMode((state) => state.setCursor);
@@ -210,6 +212,7 @@ export default function EventBlock(props: EventBlockProps) {
isRolling={isRolling}
actionHandler={actionHandler}
disableEdit={disableEdit}
isFirstEvent={isFirstEvent}
/>
)}
</div>
@@ -11,9 +11,11 @@ import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward'
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { EndAction, Playback, TimerType } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import { useAppMode } from '../../../common/stores/appModeStore';
import { millisToDelayString } from '../../../common/utils/dateConfig';
import { tooltipDelayMid } from '../../../ontimeConfig';
import EditableBlockTitle from '../common/EditableBlockTitle';
import { EventItemActions } from '../RundownEntry';
@@ -53,6 +55,7 @@ interface EventBlockInnerProps {
isRolling: boolean;
actionHandler: (action: EventItemActions, payload?: any) => void;
disableEdit: boolean;
isFirstEvent: boolean;
}
const EventBlockInner = (props: EventBlockInnerProps) => {
@@ -76,6 +79,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
isRolling,
actionHandler,
disableEdit,
isFirstEvent,
} = props;
const [renderInner, setRenderInner] = useState(false);
@@ -103,6 +107,19 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
playBtnStyles._hover = {};
}
const delayedStart = Math.max(0, timeStart + delay);
const newTime = millisToString(delayedStart);
const delayTime = delay !== 0 ? millisToDelayString(delay) : null;
const overlap = previousEnd - timeStart;
const overlapTime = !isFirstEvent
? overlap > 0
? `Overlapping ${millisToDelayString(overlap)}`
: overlap < 0
? `Spacing ${millisToDelayString(overlap)}`
: null
: null;
return !renderInner ? null : (
<>
<EventBlockTimers
@@ -114,10 +131,35 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
previousEnd={previousEnd}
/>
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
{next && (
{next ? (
<Tooltip label='Next event' {...tooltipProps}>
<span className={style.nextTag}>UP NEXT</span>
</Tooltip>
) : (
<span className={style.indicators}>
{delayTime && (
<Tooltip
label={
<div>
{delayTime} <br />
New Time: {newTime}
</div>
}
>
<div className={`${style.indicator} ${style.delay}`} />
</Tooltip>
)}
{overlapTime && (
<Tooltip label={overlapTime}>
<div className={`${style.indicator} ${overlap > 0 ? style.overlap : style.spacing}`} />
</Tooltip>
)}
{timeStart > timeEnd && (
<Tooltip label='Start time is later than end'>
<div className={`${style.indicator} ${style.nextDay}`} />
</Tooltip>
)}
</span>
)}
<EventBlockPlayback
eventId={eventId}
@@ -1,11 +1,10 @@
import { memo, useCallback, useState } from 'react';
import { memo } from 'react';
import { OntimeEvent } from 'ontime-types';
import { calculateDuration, millisToString } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../../common/hooks/useEventAction';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { TimeEntryField, validateEntry } from '../../../../common/utils/timesManager';
import style from '../EventBlock.module.scss';
@@ -24,8 +23,6 @@ const EventBlockTimers = (props: EventBlockTimerProps) => {
const { eventId, timeStart, timeEnd, duration, delay, previousEnd } = props;
const { updateEvent } = useEventAction();
const [warning, setWarnings] = useState({ start: '', end: '', duration: '' });
const handleSubmit = (field: TimeActions, value: number) => {
const newEventData: Partial<OntimeEvent> = { id: eventId };
switch (field) {
@@ -49,21 +46,6 @@ const EventBlockTimers = (props: EventBlockTimerProps) => {
updateEvent(newEventData);
};
/**
* @description Validates a time input against its pair
* @param {string} entry - field to validate: timeStart, timeEnd, durationOverride
* @param {number} val - field value
* @return {boolean}
*/
const handleValidation = useCallback(
(field: TimeEntryField, value: number) => {
const valid = validateEntry(field, value, timeStart, timeEnd);
setWarnings((prev) => ({ ...prev, ...valid.warnings }));
return valid.value;
},
[timeEnd, timeStart],
);
const delayedStart = Math.max(0, timeStart + delay);
const newTime = millisToString(delayedStart);
const delayTime = delay !== 0 ? millisToDelayString(delay) : null;
@@ -73,32 +55,26 @@ const EventBlockTimers = (props: EventBlockTimerProps) => {
<TimeInput
name='timeStart'
submitHandler={handleSubmit}
validationHandler={handleValidation}
time={timeStart}
delay={delay}
placeholder='Start'
previousEnd={previousEnd}
warning={warning.start}
/>
<TimeInput
name='timeEnd'
submitHandler={handleSubmit}
validationHandler={handleValidation}
time={timeEnd}
delay={delay}
placeholder='End'
previousEnd={previousEnd}
warning={warning.end}
/>
<TimeInput
name='durationOverride'
submitHandler={handleSubmit}
validationHandler={handleValidation}
time={duration}
delay={0}
placeholder='Duration'
previousEnd={previousEnd}
warning={warning.duration}
/>
{delayTime && (
<div className={style.delayNote}>
@@ -13,7 +13,7 @@ $half-hours: min(1.5vh, 10px);
$size-min: min(2.5vh, 18px);
$half-min: min(1.25vh, 9px);
$red-active: #c53030;
$red-idle: #000000;
$red-idle: #300000;
$cyan-active: #0ff;
$cyan-idle: #0aa;
@@ -109,7 +109,8 @@ $cyan-idle: #0aa;
line-height: 1em;
&--overtime {
color: darken($red-active, 10%);
color: var(--studio-active, $red-active);
filter: brightness(0.9);
}
}
@@ -133,7 +134,7 @@ $cyan-idle: #0aa;
color: var(--studio-active, $red-active);
&--idle {
color: var(--studio-idle, $red-active);
color: var(--studio-idle, $red-idle);
}
}
+26 -1
View File
@@ -12,13 +12,38 @@ import './index.scss';
const container = document.getElementById('root');
const root = createRoot(container as Element);
// https://docs.sentry.io/platforms/javascript/configuration/filtering/#decluttering-sentry
const sentryRecommendedIgnore = [
// Random plugins/extensions
'top.GLOBALS',
// See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error.html
'originalCreateNotification',
'canvas.contentDocument',
'MyApp_RemoveAllHighlights',
'http://tt.epicplay.com',
"Can't find variable: ZiteReader",
'jigsaw is not defined',
'ComboSearch is not defined',
'http://loading.retry.widdit.com/',
'atomicFindClose',
// Facebook borked
'fb_xd_fragment',
// ISP "optimizing" proxy - `Cache-Control: no-transform` seems to
// reduce this. (thanks @acdha)
// See http://stackoverflow.com/questions/4113268
'bmi_SafeAddOnload',
'EBCallBackMessageReceived',
// See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
'conduitPage',
];
Sentry.init({
dsn: 'https://5e4d2c4b57ab409cb98d4c08b2014755@o4504288369836032.ingest.sentry.io/4504288371343360',
integrations: [new BrowserTracing()],
tracesSampleRate: 0.3,
release: ONTIME_VERSION,
enabled: import.meta.env.PROD,
ignoreErrors: ['top.GLOBALS', 'Unable to preload CSS', 'Failed to fetch dynamically imported module'],
ignoreErrors: [...sentryRecommendedIgnore, 'Unable to preload CSS', 'Failed to fetch dynamically imported module'],
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
});
@@ -6,6 +6,7 @@ import { langDe } from './languages/de';
import { langEn } from './languages/en';
import { langEs } from './languages/es';
import { langFr } from './languages/fr';
import { langIt } from './languages/it';
import { langNo } from './languages/no';
import { langPt } from './languages/pt';
import { langSv } from './languages/sv';
@@ -14,6 +15,7 @@ const translationsList = {
en: langEn,
es: langEs,
fr: langFr,
it: langIt,
de: langDe,
no: langNo,
pt: langPt,
@@ -0,0 +1,19 @@
import { TranslationObject } from './en';
export const langIt: TranslationObject = {
'common.end_time': 'Ora di Fine',
'common.expected_finish': 'Fine Prevista',
'common.now': 'Adesso',
'common.next': 'Prossimo',
'common.public_message': 'Messaggio pubblico',
'common.start_time': 'Ora di Inizio',
'common.stage_timer': 'Orologio Palco',
'common.started_at': 'Iniziato Alle',
'common.time_now': 'Ora attuale',
'countdown.ended': 'Evento finito alle',
'countdown.running': 'Evento in corso',
'countdown.select_event': 'Seleziona un evento da seguire',
'countdown.to_start': 'Tempo alla partenza',
'countdown.waiting': 'In attesa dell\'inizio dell\'evento',
'countdown.overtime': 'in ritardo',
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "2.24.8",
"version": "2.28.11",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+4 -1
View File
@@ -2,9 +2,10 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "2.24.8",
"version": "2.28.11",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
"body-parser": "^1.20.0",
"cors": "^2.8.5",
"dotenv": "^16.0.1",
@@ -13,6 +14,7 @@
"express-static-gzip": "^2.1.7",
"express-validator": "^6.14.2",
"got": "^14.0.0",
"google-auth-library": "^9.2.0",
"lowdb": "^5.0.5",
"multer": "^1.4.5-lts.1",
"node-osc": "^9.0.2",
@@ -53,6 +55,7 @@
"build:electron": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --outfile=dist/index.cjs",
"build:local": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --bundle --minify --outfile=dist/index.cjs",
"build:docker": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --outfile=dist/docker.cjs",
"build:localdocker": "NODE_ENV=local pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --outfile=dist/docker.cjs",
"build:debug": "pnpm prebuild && esbuild src/app.ts --platform=node --format=cjs --bundle --outfile=dist/index.cjs",
"lint": "eslint . --quiet",
"lint-staged": "eslint",
+15 -2
View File
@@ -8,7 +8,14 @@ import cors from 'cors';
// import utils
import { join, resolve } from 'path';
import { currentDirectory, environment, externalsStartDirectory, isProduction, resolvedPath } from './setup.js';
import {
currentDirectory,
environment,
isProduction,
resolveExternalsDirectory,
resolveStylesDirectory,
resolvedPath,
} from './setup.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
// Import Routes
@@ -35,6 +42,7 @@ import { eventStore, getInitialPayload } from './stores/EventStore.js';
import { PlaybackService } from './services/PlaybackService.js';
import { RestorePoint, restoreService } from './services/RestoreService.js';
import { messageService } from './services/message-service/MessageService.js';
import { populateDemo } from './modules/loadDemo.js';
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
@@ -64,7 +72,11 @@ app.use('/ontime', ontimeRouter);
app.use('/playback', playbackRouter);
// serve static - css
app.use('/external', express.static(externalsStartDirectory));
app.use('/external/styles', express.static(resolveStylesDirectory));
app.use('/external/', express.static(resolveExternalsDirectory));
app.use('/external', (req, res) => {
res.status(404).send(`${req.originalUrl} not found`);
});
// serve static - react, in dev/test mode we fetch the React app from module
const reactAppPath = join(currentDirectory, resolvedPath());
@@ -126,6 +138,7 @@ export const initAssets = async () => {
checkStart(OntimeStartOrder.InitAssets);
await dbLoadingProcess;
populateStyles();
populateDemo();
};
/**
@@ -97,7 +97,6 @@ describe('safeMerge', () => {
language: 'pt',
});
});
it('merges the osc key', () => {
const newData = {
osc: {
+4
View File
@@ -8,5 +8,9 @@ export const config = {
directory: 'styles',
filename: 'override.css',
},
demo: {
directory: 'demo',
filename: ['app.js', 'index.html', 'styles.css'],
},
restoreFile: 'ontime.restore',
};
@@ -139,11 +139,10 @@ export function dispatchFromAdapter(
throw new Error(`Event index not recognised or out of range ${eventIndex}`);
}
try {
// Indexes in frontend are 1 based
PlaybackService.startByIndex(eventIndex - 1);
} catch (error) {
throw new Error(`Error loading event:: ${error}`);
// Indexes in frontend are 1 based
const success = PlaybackService.startByIndex(eventIndex - 1);
if (!success) {
throw new Error(`Event index not recognised or out of range ${eventIndex}`);
}
break;
}
@@ -194,11 +193,8 @@ export function dispatchFromAdapter(
if (isNaN(time)) {
throw new Error(`Time not recognised ${payload}`);
}
try {
PlaybackService.addTime(time);
} catch (error) {
throw new Error(`Could not add time: ${error}`);
}
PlaybackService.addTime(time);
break;
}
//deprecated
@@ -208,11 +204,7 @@ export function dispatchFromAdapter(
throw new Error(`Delay time not recognised ${payload}`);
}
try {
PlaybackService.setDelay(delayTime);
} catch (error) {
throw new Error(`Could not add delay: ${error}`);
}
PlaybackService.setDelay(delayTime);
break;
}
case 'gotoindex':
@@ -222,11 +214,10 @@ export function dispatchFromAdapter(
throw new Error(`Event index not recognised or out of range ${eventIndex}`);
}
try {
// Indexes in frontend are 1 based
PlaybackService.loadByIndex(eventIndex - 1);
} catch (error) {
throw new Error(`Event index not recognised or out of range ${error}`);
// Indexes in frontend are 1 based
const success = PlaybackService.loadByIndex(eventIndex - 1);
if (!success) {
throw new Error(`Event index not recognised or out of range ${eventIndex}`);
}
break;
}
@@ -236,10 +227,9 @@ export function dispatchFromAdapter(
throw new Error(`Event ID not recognised: ${payload}`);
}
try {
PlaybackService.loadById(payload.toString().toLowerCase());
} catch (error) {
throw new Error(`OSC IN: error calling goto ${error}`);
const success = PlaybackService.loadById(payload.toString().toLowerCase());
if (!success) {
throw new Error(`Event ID not found: ${payload}`);
}
break;
}
@@ -249,10 +239,9 @@ export function dispatchFromAdapter(
throw new Error(`Event cue not recognised: ${payload}`);
}
try {
PlaybackService.loadByCue(payload);
} catch (error) {
throw new Error(`OSC IN: error calling goto ${error}`);
const success = PlaybackService.loadByCue(payload);
if (!success) {
throw new Error(`Event cue not found: ${payload}`);
}
break;
}
+123 -2
View File
@@ -10,7 +10,7 @@ import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
import { PlaybackService } from '../services/PlaybackService.js';
import { eventStore } from '../stores/EventStore.js';
import { isDocker, pathToStartStyles, resolveDbPath } from '../setup.js';
import { isDocker, resolveDbPath, resolveStylesPath } from '../setup.js';
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
import { httpIntegration } from '../services/integration-service/HttpIntegration.js';
import { logger } from '../classes/Logger.js';
@@ -20,6 +20,8 @@ import { runtimeCacheStore } from '../stores/cachingStore.js';
import { delayedRundownCacheKey } from '../services/rundown-service/delayedRundown.utils.js';
import { integrationService } from '../services/integration-service/IntegrationService.js';
import { sheet } from '../utils/sheetsAuth.js';
// Create controller for GET request to '/ontime/poll'
// Returns data for current state
export const poll = async (req, res) => {
@@ -117,7 +119,7 @@ export const getInfo = async (req: Request, res: Response<GetInfo>) => {
// get nif and inject localhost
const ni = getNetworkInterfaces();
ni.unshift({ name: 'localhost', address: '127.0.0.1' });
const cssOverride = pathToStartStyles;
const cssOverride = resolveStylesPath;
// send object with network information
res.status(200).send({
@@ -455,3 +457,122 @@ export const postNew: RequestHandler = async (req, res) => {
res.status(400).send({ message: error.toString() });
}
};
//SHEET Functions
/**
* @description SETP-1 POST Client Secrect
* @returns parsed result
*/
export async function uploadSheetClientFile(req, res) {
if (!req.file.path) {
res.status(400).send({ message: 'File not found' });
return;
}
try {
const client = JSON.parse(fs.readFileSync(req.file.path as string, 'utf-8'));
await sheet.saveClientSecrets(client);
res.status(200).send('OK');
} catch (error) {
res.status(500).send({ message: error.toString() });
}
fs.unlink(req.file.path, (err) => {
if (err) logger.error(LogOrigin.Server, err.message);
});
}
/**
* @description SETP-1 GET Client Secrect status
*/
export const getClientSecrect = async (req, res) => {
try {
const clientSecrectExists = await sheet.testClientSecret();
if (clientSecrectExists) {
res.status(200).send();
} else {
res.status(500).send({ message: 'The Client ID does not exist' });
}
} catch (error) {
res.status(500).send({ message: error.toString() });
}
};
/**
* @description SETP-2 GET sheet authentication url
*/
export async function getAuthenticationUrl(req, res) {
try {
const authUrl = await sheet.openAuthServer();
res.status(200).send(authUrl);
} catch (error) {
res.status(500).send({ message: error.toString() });
}
}
/**
* @description SETP-2 GET sheet authentication status
*/
export const getAuthentication = async (req, res) => {
try {
await sheet.testAuthentication();
res.status(200).send();
} catch (error) {
res.status(500).send({ message: error.toString() });
}
};
/**
* @description SETP-3 POST sheet id
* @returns list of worksheets
*/
export const postId = async (req, res) => {
try {
const { id } = req.body;
if (id.lenght < 40) {
res.status(400).send({ message: 'ID is usualy 44 characters long' });
}
const state = await sheet.testSheetId(id);
res.status(200).send(state);
} catch (error) {
res.status(500).send({ message: error.toString() });
}
};
/**
* @description SETP-4 POST worksheet
*/
export const postWorksheet = async (req, res) => {
try {
const { worksheet, id } = req.body;
const state = await sheet.testWorksheet(worksheet, id);
res.status(200).send(state);
} catch (error) {
res.status(500).send({ message: error.toString() });
}
};
/**
* @description STEP-5 POST download undown to sheet
* @returns parsed result
*/
export async function pullSheet(req, res) {
try {
const { id, options } = req.body;
const data = await sheet.pull(id, options);
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: error.toString() });
}
}
/**
* @description STEP-5 POST upload rundown to sheet
*/
export async function pushSheet(req, res) {
try {
const { id, options } = req.body;
await sheet.push(id, options);
res.status(200).send();
} catch (error) {
res.status(500).send({ message: error.toString() });
}
}
@@ -152,3 +152,32 @@ export const validatePatchProjectFile = [
next();
},
];
export const validateSheetid = [
body('id').exists().isString(),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateWorksheet = [
body('id').exists().isString(),
body('worksheet').exists().isString(),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateSheetOptions = [
body('id').exists().isString(),
// body('options').exists().isObject(), TODO:
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
+67
View File
@@ -0,0 +1,67 @@
/*eslint-env browser*/
/**
* This is a very minimal example for a websocket client
* You could use this as a starting point to creating your own interfaces
*/
const mts = 1000; // millis to seconds
const mtm = 1000 * 60; // millis to minutes
const mth = 1000 * 60 * 60; // millis to hours
const leftPad = (number) => {
return Math.floor(number).toString().padStart(2, '0');
};
let reconnectTimeout;
const reconnectInterval = 1000;
let reconnectAttempts = 0;
const connectSocket = () => {
const websocket = new WebSocket(`ws://${window.location.hostname}:${window.location.port}/ws`);
websocket.onopen = () => {
clearTimeout(reconnectTimeout);
reconnectAttempts = 0;
console.info('WebSocket connected');
};
websocket.onclose = () => {
console.warn('WebSocket disconnected');
reconnectTimeout = setTimeout(() => {
console.warn(`WebSocket: attempting reconnect ${reconnectAttempts}`);
if (websocket && websocket.readyState === WebSocket.CLOSED) {
reconnectAttempts += 1;
connectSocket();
}
}, reconnectInterval);
};
websocket.onerror = (error) => {
console.error('WebSocket error:', error);
};
websocket.onmessage = (event) => {
const data = JSON.parse(event.data);
// all objects from ontime are structured with type and payload
const { type, payload } = data;
// we only need to read message type of ontime
if (type === 'ontime') {
// destructure known data from ontime
// see https://cpvalente.gitbook.io/ontime/control-and-feedback/websocket-api
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)}`;
}
}
};
};
connectSocket();
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>ontime demo</title>
<link href="./styles.css" rel="stylesheet" />
</head>
<body>
<div id="timer"></div>
<script src="./app.js" type="module"></script>
</html>
+16
View File
@@ -0,0 +1,16 @@
body {
overflow: hidden;
margin: 0;
}
div {
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
color: azure;
font-family: monospace;
font-size: 20vw;
background-color: black;
}
+21
View File
@@ -0,0 +1,21 @@
import { copyFile } from 'fs/promises';
import { pathToStartDemo, resolveDemoDirectory, resolveDemoPath } from '../setup.js';
import { ensureDirectory } from '../utils/fileManagement.js';
/**
* @description ensures directories exist and populates demo folder
*/
export const populateDemo = () => {
ensureDirectory(resolveDemoDirectory);
// even if demo exist we want to use startup demo
try {
Promise.all(
resolveDemoPath.map((to, index) => {
const from = pathToStartDemo[index];
copyFile(from, to);
}),
);
} catch (_) {
/* we do not handle this */
}
};
+3 -8
View File
@@ -4,20 +4,15 @@ import { ensureDirectory } from '../utils/fileManagement.js';
/**
* @description ensures directories exist and populates stylesheet
* @return {string} - path to stylesheet file
*/
export const populateStyles = () => {
const stylesInDisk = resolveStylesPath;
ensureDirectory(resolveStylesDirectory);
// if stylesInDisk doesn't exist we want to use startup stylesheet
if (!existsSync(stylesInDisk)) {
// if styles doesn't exist we want to use startup stylesheet
if (!existsSync(resolveStylesPath)) {
try {
copyFileSync(pathToStartStyles, stylesInDisk);
copyFileSync(pathToStartStyles, resolveStylesPath);
} catch (_) {
/* we do not handle this */
}
}
return stylesInDisk;
};
+30
View File
@@ -21,6 +21,13 @@ import {
postViewSettings,
previewExcel,
postHTTP,
getAuthenticationUrl,
uploadSheetClientFile as uploadClientSecret,
pullSheet,
pushSheet,
postId,
getAuthentication,
getClientSecrect as getClientSecret,
} from '../controllers/ontimeController.js';
import {
@@ -32,6 +39,9 @@ import {
viewValidator,
validateHTTP,
validateOscSubscription,
validateSheetid,
validateWorksheet,
validateSheetOptions,
} from '../controllers/ontimeController.validate.js';
import { projectSanitiser } from '../controllers/projectController.validate.js';
@@ -96,3 +106,23 @@ router.post('/http', validateHTTP, postHTTP);
// create route between controller and '/ontime/new' endpoint
router.post('/new', projectSanitiser, postNew);
//SETP-1
router.post('/sheet/clientsecret', uploadFile, uploadClientSecret);
router.get('/sheet/clientsecret', uploadFile, getClientSecret);
//SETP-2
router.get('/sheet/authentication/url', getAuthenticationUrl);
router.get('/sheet/authentication', getAuthentication);
//STEP-3
router.post('/sheet/id', validateSheetid, postId);
//STEP-4
router.post('/sheet/worksheet', validateWorksheet, postId);
//STEP-5 download and generate preview
router.post('/sheet/pull', validateSheetOptions, pullSheet);
//STEP-5 upload
router.post('/sheet-push', validateSheetOptions, pushSheet);
+17 -1
View File
@@ -68,7 +68,8 @@ export const currentDirectory = dirname(__dirname);
const testDbStartDirectory = isTest ? '../' : getAppDataPath();
export const externalsStartDirectory = isProduction ? getAppDataPath() : join(currentDirectory, 'external');
//TODO: we only need one when they are all in the same folder
export const resolveExternalsDirectory = join(isProduction ? getAppDataPath() : currentDirectory, 'external');
// path to public db
export const resolveDbDirectory = join(
testDbStartDirectory,
@@ -80,11 +81,26 @@ export const pathToStartDb = isTest
? join(currentDirectory, '../', config.database.testdb, config.database.filename)
: join(currentDirectory, '/preloaded-db/', config.database.filename);
//TODO: move all static files to the external directory
// path to public styles
export const resolveStylesDirectory = join(externalsStartDirectory, config.styles.directory);
export const resolveStylesPath = join(resolveStylesDirectory, config.styles.filename);
export const pathToStartStyles = join(currentDirectory, '/external/styles/', config.styles.filename);
// path to public demo
export const resolveDemoDirectory = join(
externalsStartDirectory,
isProduction ? '/external/' : '', //move to external folde in production
config.demo.directory,
);
export const resolveDemoPath = config.demo.filename.map((file) => {
return join(resolveDemoDirectory, file);
});
export const pathToStartDemo = config.demo.filename.map((file) => {
return join(currentDirectory, '/external/demo/', file);
});
// path to restore file
export const resolveRestoreFile = join(getAppDataPath(), config.restoreFile);
@@ -0,0 +1,433 @@
import { millisToString } from 'ontime-utils';
import { getA1Notation, cellRequestFromEvent, cellRequenstFromProjectData } from '../sheetUtils.js';
import { EndAction, OntimeRundownEntry, ProjectData, SupportedEvent, TimerType } from 'ontime-types';
describe('getA1Notation()', () => {
test('A1', () => {
expect(getA1Notation(0, 0)).toStrictEqual('A1');
});
test('E3', () => {
expect(getA1Notation(2, 4)).toStrictEqual('E3');
});
test('AA100', () => {
expect(getA1Notation(99, 26)).toStrictEqual('AA100');
});
test('can not be negative', () => {
expect(() => getA1Notation(-1, 1)).toThrowError('Index can not be less than 0');
});
});
describe('cellRequenstFromEvent()', () => {
test('string to string', () => {
const event: OntimeRundownEntry = {
type: SupportedEvent.Event,
cue: '1',
title: 'Fancy',
subtitle: 'Wow',
presenter: 'Mr. Presenter',
note: 'Blue button on the right',
timeStart: 46800000,
timeEnd: 57600000,
endAction: EndAction.None,
timerType: TimerType.CountDown,
duration: 10800000,
isPublic: false,
skip: false,
colour: 'red',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
revision: 0,
id: '1358',
};
const metadata = {
type: { row: 1, col: 14 },
cue: { row: 1, col: 15 },
title: { row: 1, col: 16 },
subtitle: { row: 1, col: 17 },
presenter: { row: 1, col: 18 },
note: { row: 1, col: 19 },
timeStart: { row: 1, col: 20 },
timeEnd: { row: 1, col: 21 },
endAction: { row: 1, col: 22 },
timerType: { row: 1, col: 23 },
duration: { row: 1, col: 24 },
isPublic: { row: 1, col: 25 },
skip: { row: 1, col: 26 },
colour: { row: 1, col: 27 },
user0: { row: 1, col: 28 },
user1: { row: 1, col: 29 },
user2: { row: 1, col: 30 },
user3: { row: 1, col: 31 },
user4: { row: 1, col: 32 },
user5: { row: 1, col: 33 },
user6: { row: 1, col: 34 },
user7: { row: 1, col: 35 },
user8: { row: 1, col: 36 },
user9: { row: 1, col: 37 },
revision: { row: 1, col: 38 },
id: { row: 1, col: 39 },
};
const result = cellRequestFromEvent(event, 1, 1234, metadata);
expect(result.updateCells.rows[0].values[5].userEnteredValue.stringValue).toStrictEqual(event.note);
});
test('numer to timer', () => {
const event: OntimeRundownEntry = {
type: SupportedEvent.Event,
cue: '1',
title: 'Fancy',
subtitle: 'Wow',
presenter: 'Mr. Presenter',
note: 'Blue button on the right',
timeStart: 46800000,
timeEnd: 57600000,
endAction: EndAction.None,
timerType: TimerType.CountDown,
duration: 10800000,
isPublic: false,
skip: false,
colour: 'red',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
revision: 0,
id: '1358',
};
const metadata = {
type: { row: 1, col: 14 },
cue: { row: 1, col: 15 },
title: { row: 1, col: 16 },
subtitle: { row: 1, col: 17 },
presenter: { row: 1, col: 18 },
note: { row: 1, col: 19 },
timeStart: { row: 1, col: 20 },
timeEnd: { row: 1, col: 21 },
endAction: { row: 1, col: 22 },
timerType: { row: 1, col: 23 },
duration: { row: 1, col: 24 },
isPublic: { row: 1, col: 25 },
skip: { row: 1, col: 26 },
colour: { row: 1, col: 27 },
user0: { row: 1, col: 28 },
user1: { row: 1, col: 29 },
user2: { row: 1, col: 30 },
user3: { row: 1, col: 31 },
user4: { row: 1, col: 32 },
user5: { row: 1, col: 33 },
user6: { row: 1, col: 34 },
user7: { row: 1, col: 35 },
user8: { row: 1, col: 36 },
user9: { row: 1, col: 37 },
revision: { row: 1, col: 38 },
id: { row: 1, col: 39 },
};
const result = cellRequestFromEvent(event, 1, 1234, metadata).updateCells.rows[0].values[10].userEnteredValue
.stringValue;
expect(result).toStrictEqual(millisToString(event.duration));
});
test('boolean to x', () => {
const event: OntimeRundownEntry = {
type: SupportedEvent.Event,
cue: '1',
title: 'Fancy',
subtitle: 'Wow',
presenter: 'Mr. Presenter',
note: 'Blue button on the right',
timeStart: 46800000,
timeEnd: 57600000,
endAction: EndAction.None,
timerType: TimerType.CountDown,
duration: 10800000,
isPublic: true,
skip: false,
colour: 'red',
user0: 'u',
user1: 'u',
user2: 'u',
user3: 'u',
user4: 'u',
user5: 'u',
user6: 'u',
user7: 'u',
user8: 'u',
user9: 'u',
revision: 0,
id: '1358',
};
const metadata = {
type: { row: 1, col: 14 },
cue: { row: 1, col: 15 },
title: { row: 1, col: 16 },
subtitle: { row: 1, col: 17 },
presenter: { row: 1, col: 18 },
note: { row: 1, col: 19 },
timeStart: { row: 1, col: 20 },
timeEnd: { row: 1, col: 21 },
endAction: { row: 1, col: 22 },
timerType: { row: 1, col: 23 },
duration: { row: 1, col: 24 },
isPublic: { row: 1, col: 25 },
skip: { row: 1, col: 26 },
colour: { row: 1, col: 27 },
user0: { row: 1, col: 28 },
user1: { row: 1, col: 29 },
user2: { row: 1, col: 30 },
user3: { row: 1, col: 31 },
user4: { row: 1, col: 32 },
user5: { row: 1, col: 33 },
user6: { row: 1, col: 34 },
user7: { row: 1, col: 35 },
user8: { row: 1, col: 36 },
user9: { row: 1, col: 37 },
revision: { row: 1, col: 38 },
id: { row: 1, col: 39 },
};
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('');
});
test('spacing in metadata', () => {
const event: OntimeRundownEntry = {
type: SupportedEvent.Event,
cue: '1',
title: 'Fancy',
subtitle: 'Wow',
presenter: 'Mr. Presenter',
note: 'Blue button on the right',
timeStart: 46800000,
timeEnd: 57600000,
endAction: EndAction.None,
timerType: TimerType.CountDown,
duration: 10800000,
isPublic: true,
skip: false,
colour: 'red',
user0: 'u',
user1: 'u',
user2: 'u',
user3: 'u',
user4: 'u',
user5: 'u',
user6: 'u',
user7: 'u',
user8: 'u',
user9: 'u',
revision: 0,
id: '1358',
};
const metadata = {
cue: { row: 1, col: 0 },
title: { row: 1, col: 6 },
subtitle: { row: 1, col: 10 },
user0: { row: 1, col: 16 },
};
const result = cellRequestFromEvent(event, 1, 1234, metadata);
expect(result.updateCells.rows[0].values[0].userEnteredValue.stringValue).toStrictEqual(event.cue);
expect(result.updateCells.rows[0].values[6].userEnteredValue.stringValue).toStrictEqual(event.title);
expect(result.updateCells.rows[0].values[10].userEnteredValue.stringValue).toStrictEqual(event.subtitle);
});
test('metadata offset from zero', () => {
const event: OntimeRundownEntry = {
type: SupportedEvent.Event,
cue: '1',
title: 'Fancy',
subtitle: 'Wow',
presenter: 'Mr. Presenter',
note: 'Blue button on the right',
timeStart: 46800000,
timeEnd: 57600000,
endAction: EndAction.None,
timerType: TimerType.CountDown,
duration: 10800000,
isPublic: true,
skip: false,
colour: 'red',
user0: 'u',
user1: 'u',
user2: 'u',
user3: 'u',
user4: 'u',
user5: 'u',
user6: 'u',
user7: 'u',
user8: 'u',
user9: 'u',
revision: 0,
id: '1358',
};
const metadata = {
cue: { row: 1, col: 5 },
title: { row: 1, col: 6 },
subtitle: { row: 1, col: 10 },
user0: { row: 1, col: 16 },
};
const result = cellRequestFromEvent(event, 1, 1234, metadata);
expect(result.updateCells.rows[0].values[0].userEnteredValue.stringValue).toStrictEqual(event.cue);
expect(result.updateCells.rows[0].values[1].userEnteredValue.stringValue).toStrictEqual(event.title);
expect(result.updateCells.rows[0].values[5].userEnteredValue.stringValue).toStrictEqual(event.subtitle);
});
test('sheet setup', () => {
const event: OntimeRundownEntry = {
type: SupportedEvent.Event,
cue: '1',
title: 'Fancy',
subtitle: 'Wow',
presenter: 'Mr. Presenter',
note: 'Blue button on the right',
timeStart: 46800000,
timeEnd: 57600000,
endAction: EndAction.None,
timerType: TimerType.CountDown,
duration: 10800000,
isPublic: true,
skip: false,
colour: 'red',
user0: 'u',
user1: 'u',
user2: 'u',
user3: 'u',
user4: 'u',
user5: 'u',
user6: 'u',
user7: 'u',
user8: 'u',
user9: 'u',
revision: 0,
id: '1358',
};
const metadata = {
cue: { row: 10, col: 5 },
title: { row: 10, col: 6 },
subtitle: { row: 1, col: 10 },
user0: { row: 10, col: 16 },
};
const result1 = cellRequestFromEvent(event, 1, 1234, metadata);
expect(result1.updateCells.start.sheetId).toStrictEqual(1234);
const result2 = cellRequestFromEvent(event, 10, 1234, metadata);
expect(result2.updateCells.start.rowIndex).toStrictEqual(21);
expect(result2.updateCells.start.columnIndex).toStrictEqual(5);
expect(result2.updateCells.fields).toStrictEqual('userEnteredValue');
});
});
describe('cellRequenstFromProjectData()', () => {
test('string to string', () => {
const projectData: ProjectData = {
title: 'Title',
description: 'Description',
publicUrl: 'Public Url',
backstageUrl: 'Backstage Url',
publicInfo: 'Public Info',
backstageInfo: 'Backstage Info',
};
const metadata = {
title: { row: 0, col: 1 },
description: { row: 1, col: 1 },
publicUrl: { row: 2, col: 1 },
backstageUrl: { row: 3, col: 1 },
publicInfo: { row: 4, col: 1 },
backstageInfo: { row: 5, col: 1 },
};
const result = cellRequenstFromProjectData(projectData, 1234, metadata);
expect(result.updateCells.rows[0].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.title);
expect(result.updateCells.rows[1].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.description);
expect(result.updateCells.rows[2].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.publicUrl);
expect(result.updateCells.rows[3].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.backstageUrl);
expect(result.updateCells.rows[4].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.publicInfo);
expect(result.updateCells.rows[5].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.backstageInfo);
});
test('metadata offset from zero', () => {
const projectData: ProjectData = {
title: 'Title',
description: 'Description',
publicUrl: 'Public Url',
backstageUrl: 'Backstage Url',
publicInfo: 'Public Info',
backstageInfo: 'Backstage Info',
};
const metadata = {
title: { row: 5, col: 10 },
description: { row: 6, col: 10 },
publicUrl: { row: 7, col: 10 },
backstageUrl: { row: 9, col: 10 },
publicInfo: { row: 10, col: 10 },
backstageInfo: { row: 11, col: 10 },
};
const result = cellRequenstFromProjectData(projectData, 1234, metadata);
expect(result.updateCells.rows[0].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.title);
expect(result.updateCells.rows[1].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.description);
expect(result.updateCells.rows[2].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.publicUrl);
expect(result.updateCells.rows[4].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.backstageUrl);
expect(result.updateCells.rows[5].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.publicInfo);
expect(result.updateCells.rows[6].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.backstageInfo);
});
test('spacing in metadata', () => {
const projectData: ProjectData = {
title: 'Title',
description: 'Description',
publicUrl: 'Public Url',
backstageUrl: 'Backstage Url',
publicInfo: 'Public Info',
backstageInfo: 'Backstage Info',
};
const metadata = {
title: { row: 0, col: 1 },
description: { row: 1, col: 1 },
publicUrl: { row: 2, col: 1 },
backstageUrl: { row: 9, col: 1 },
publicInfo: { row: 15, col: 1 },
backstageInfo: { row: 50, col: 1 },
};
const result = cellRequenstFromProjectData(projectData, 1234, metadata);
expect(result.updateCells.rows[0].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.title);
expect(result.updateCells.rows[1].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.description);
expect(result.updateCells.rows[2].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.publicUrl);
expect(result.updateCells.rows[9].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.backstageUrl);
expect(result.updateCells.rows[15].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.publicInfo);
expect(result.updateCells.rows[50].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.backstageInfo);
});
test('sheet setup', () => {
const projectData: ProjectData = {
title: 'Title',
description: 'Description',
publicUrl: 'Public Url',
backstageUrl: 'Backstage Url',
publicInfo: 'Public Info',
backstageInfo: 'Backstage Info',
};
const metadata = {
title: { row: 0, col: 10 },
description: { row: 1, col: 10 },
publicUrl: { row: 2, col: 10 },
backstageUrl: { row: 3, col: 10 },
publicInfo: { row: 4, col: 10 },
backstageInfo: { row: 5, col: 10 },
};
const result = cellRequenstFromProjectData(projectData, 1234, metadata);
expect(result.updateCells.start.rowIndex).toStrictEqual(0);
expect(result.updateCells.start.columnIndex).toStrictEqual(11);
expect(result.updateCells.fields).toStrictEqual('userEnteredValue');
});
});
+215 -122
View File
@@ -36,6 +36,7 @@ import {
parseViewSettings,
} from './parserFunctions.js';
import { parseExcelDate } from './time.js';
import { coerceBoolean } from './coerceType.js';
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
export const JSON_MIME = 'application/json';
@@ -47,6 +48,8 @@ export const JSON_MIME = 'application/json';
* @returns {object} - parsed object
*/
export const parseExcel = (excelData: unknown[][], options?: Partial<ExcelImportMap>) => {
const projectMetadata = {};
const rundownMetadata = {};
const importMap: ExcelImportMap = { ...defaultExcelImportMap, ...options };
const projectData: Partial<ProjectData> = {
title: '',
@@ -103,140 +106,228 @@ export const parseExcel = (excelData: unknown[][], options?: Partial<ExcelImport
let user8Index: number | null = null;
let user9Index: number | null = null;
excelData
.filter((e) => e.length > 0)
.forEach((row) => {
// these fields contain the data to its right
let projectTitleNext = false;
let projectDescriptionNext = false;
let publicUrlNext = false;
let publicInfoNext = false;
let backstageUrlNext = false;
let backstageInfoNext = false;
excelData.forEach((row, rowIndex) => {
if (row.length === 0) {
return;
}
// these fields contain the data to its right
let projectTitleNext = false;
let projectDescriptionNext = false;
let publicUrlNext = false;
let publicInfoNext = false;
let backstageUrlNext = false;
let backstageInfoNext = false;
const event: Partial<OntimeEvent> = {};
const handlers = {
[importMap.projectName]: () => (projectTitleNext = true),
[importMap.projectDescription]: () => (projectDescriptionNext = true),
[importMap.publicUrl]: () => (publicUrlNext = true),
[importMap.publicInfo]: () => (publicInfoNext = true),
[importMap.backstageUrl]: () => (backstageUrlNext = true),
[importMap.backstageInfo]: () => (backstageInfoNext = true),
const event: Partial<OntimeEvent> = {};
const handlers = {
[importMap.projectName]: (row: number, col: number) => {
projectTitleNext = true;
projectMetadata['title'] = { row, col };
},
[importMap.projectDescription]: (row: number, col: number) => {
projectDescriptionNext = true;
projectMetadata['description'] = { row, col };
},
[importMap.publicUrl]: (row: number, col: number) => {
publicUrlNext = true;
projectMetadata['publicUrl'] = { row, col };
},
[importMap.publicInfo]: (row: number, col: number) => {
publicInfoNext = true;
projectMetadata['publicInfo'] = { row, col };
},
[importMap.backstageUrl]: (row: number, col: number) => {
backstageUrlNext = true;
projectMetadata['backstageUrl'] = { row, col };
},
[importMap.backstageInfo]: (row: number, col: number) => {
backstageInfoNext = true;
projectMetadata['backstageInfo'] = { row, col };
},
[importMap.timeStart]: (index: number) => (timeStartIndex = index),
[importMap.timeEnd]: (index: number) => (timeEndIndex = index),
[importMap.duration]: (index: number) => (durationIndex = index),
[importMap.timeStart]: (row: number, col: number) => {
timeStartIndex = col;
rundownMetadata['timeStart'] = { row, col };
},
[importMap.timeEnd]: (row: number, col: number) => {
timeEndIndex = col;
rundownMetadata['timeEnd'] = { row, col };
},
[importMap.duration]: (row: number, col: number) => {
durationIndex = col;
rundownMetadata['duration'] = { row, col };
},
[importMap.cue]: (index: number) => (cueIndex = index),
[importMap.title]: (index: number) => (titleIndex = index),
[importMap.presenter]: (index: number) => (presenterIndex = index),
[importMap.subtitle]: (index: number) => (subtitleIndex = index),
[importMap.isPublic]: (index: number) => (isPublicIndex = index),
[importMap.skip]: (index: number) => (skipIndex = index),
[importMap.note]: (index: number) => (notesIndex = index),
[importMap.colour]: (index: number) => (colourIndex = index),
[importMap.cue]: (row: number, col: number) => {
cueIndex = col;
rundownMetadata['cue'] = { row, col };
},
[importMap.title]: (row: number, col: number) => {
titleIndex = col;
rundownMetadata['title'] = { row, col };
},
[importMap.presenter]: (row: number, col: number) => {
presenterIndex = col;
rundownMetadata['presenter'] = { row, col };
},
[importMap.subtitle]: (row: number, col: number) => {
subtitleIndex = col;
rundownMetadata['subtitle'] = { row, col };
},
[importMap.isPublic]: (row: number, col: number) => {
isPublicIndex = col;
rundownMetadata['isPublic'] = { row, col };
},
[importMap.skip]: (row: number, col: number) => {
skipIndex = col;
rundownMetadata['skip'] = { row, col };
},
[importMap.note]: (row: number, col: number) => {
notesIndex = col;
rundownMetadata['note'] = { row, col };
},
[importMap.colour]: (row: number, col: number) => {
colourIndex = col;
rundownMetadata['colour'] = { row, col };
},
[importMap.endAction]: (index: number) => (endActionIndex = index),
[importMap.timerType]: (index: number) => (timerTypeIndex = index),
[importMap.endAction]: (row: number, col: number) => {
endActionIndex = col;
rundownMetadata['endAction'] = { row, col };
},
[importMap.timerType]: (row: number, col: number) => {
timerTypeIndex = col;
rundownMetadata['timerType'] = { row, col };
},
[importMap.user0]: (index: number) => (user0Index = index),
[importMap.user1]: (index: number) => (user1Index = index),
[importMap.user2]: (index: number) => (user2Index = index),
[importMap.user3]: (index: number) => (user3Index = index),
[importMap.user4]: (index: number) => (user4Index = index),
[importMap.user5]: (index: number) => (user5Index = index),
[importMap.user6]: (index: number) => (user6Index = index),
[importMap.user7]: (index: number) => (user7Index = index),
[importMap.user8]: (index: number) => (user8Index = index),
[importMap.user9]: (index: number) => (user9Index = index),
} as const;
[importMap.user0]: (row: number, col: number) => {
user0Index = col;
rundownMetadata['user0'] = { row, col };
},
[importMap.user1]: (row: number, col: number) => {
user1Index = col;
rundownMetadata['user1'] = { row, col };
},
[importMap.user2]: (row: number, col: number) => {
user2Index = col;
rundownMetadata['user2'] = { row, col };
},
[importMap.user3]: (row: number, col: number) => {
user3Index = col;
rundownMetadata['user3'] = { row, col };
},
[importMap.user4]: (row: number, col: number) => {
user4Index = col;
rundownMetadata['user4'] = { row, col };
},
[importMap.user5]: (row: number, col: number) => {
user5Index = col;
rundownMetadata['user5'] = { row, col };
},
[importMap.user6]: (row: number, col: number) => {
user6Index = col;
rundownMetadata['user6'] = { row, col };
},
[importMap.user7]: (row: number, col: number) => {
user7Index = col;
rundownMetadata['user7'] = { row, col };
},
[importMap.user8]: (row: number, col: number) => {
user8Index = col;
rundownMetadata['user8'] = { row, col };
},
[importMap.user9]: (row: number, col: number) => {
user9Index = col;
rundownMetadata['user9'] = { row, col };
},
} as const;
row.forEach((column, j) => {
// 1. we check if we have set a flag for a known field
if (projectTitleNext) {
projectData.title = makeString(column, '');
projectTitleNext = false;
} else if (projectDescriptionNext) {
projectData.description = makeString(column, '');
projectDescriptionNext = false;
} else if (publicUrlNext) {
projectData.publicUrl = makeString(column, '');
publicUrlNext = false;
} else if (publicInfoNext) {
projectData.publicInfo = makeString(column, '');
publicInfoNext = false;
} else if (backstageUrlNext) {
projectData.backstageUrl = makeString(column, '');
backstageUrlNext = false;
} else if (backstageInfoNext) {
projectData.backstageInfo = makeString(column, '');
backstageInfoNext = false;
} else if (j === timeStartIndex) {
event.timeStart = parseExcelDate(column);
} else if (j === timeEndIndex) {
event.timeEnd = parseExcelDate(column);
} else if (j === durationIndex) {
event.duration = parseExcelDate(column);
} else if (j === titleIndex) {
event.title = makeString(column, '');
} else if (j === cueIndex) {
event.cue = makeString(column, '');
} else if (j === presenterIndex) {
event.presenter = makeString(column, '');
} else if (j === subtitleIndex) {
event.subtitle = makeString(column, '');
} else if (j === isPublicIndex) {
event.isPublic = Boolean(column);
} else if (j === skipIndex) {
event.skip = Boolean(column);
} else if (j === notesIndex) {
event.note = makeString(column, '');
} else if (j === endActionIndex) {
event.endAction = validateEndAction(column);
} else if (j === timerTypeIndex) {
event.timerType = validateTimerType(column);
} else if (j === colourIndex) {
event.colour = makeString(column, '');
} else if (j === user0Index) {
event.user0 = makeString(column, '');
} else if (j === user1Index) {
event.user1 = makeString(column, '');
} else if (j === user2Index) {
event.user2 = makeString(column, '');
} else if (j === user3Index) {
event.user3 = makeString(column, '');
} else if (j === user4Index) {
event.user4 = makeString(column, '');
} else if (j === user5Index) {
event.user5 = makeString(column, '');
} else if (j === user6Index) {
event.user6 = makeString(column, '');
} else if (j === user7Index) {
event.user7 = makeString(column, '');
} else if (j === user8Index) {
event.user8 = makeString(column, '');
} else if (j === user9Index) {
event.user9 = makeString(column, '');
} else {
// 2. if there is no flag, lets see if we know the field type
if (typeof column === 'string') {
const col = column.toLowerCase();
row.forEach((column, j) => {
// 1. we check if we have set a flag for a known field
if (projectTitleNext) {
projectData.title = makeString(column, '');
projectTitleNext = false;
} else if (projectDescriptionNext) {
projectData.description = makeString(column, '');
projectDescriptionNext = false;
} else if (publicUrlNext) {
projectData.publicUrl = makeString(column, '');
publicUrlNext = false;
} else if (publicInfoNext) {
projectData.publicInfo = makeString(column, '');
publicInfoNext = false;
} else if (backstageUrlNext) {
projectData.backstageUrl = makeString(column, '');
backstageUrlNext = false;
} else if (backstageInfoNext) {
projectData.backstageInfo = makeString(column, '');
backstageInfoNext = false;
} else if (j === timeStartIndex) {
event.timeStart = parseExcelDate(column);
} else if (j === timeEndIndex) {
event.timeEnd = parseExcelDate(column);
} else if (j === durationIndex) {
event.duration = parseExcelDate(column);
} else if (j === titleIndex) {
event.title = makeString(column, '');
} else if (j === cueIndex) {
event.cue = makeString(column, '');
} else if (j === presenterIndex) {
event.presenter = makeString(column, '');
} else if (j === subtitleIndex) {
event.subtitle = makeString(column, '');
} else if (j === isPublicIndex) {
event.isPublic = column == 'x' ? true : coerceBoolean(column);
} else if (j === skipIndex) {
event.skip = column == 'x' ? true : coerceBoolean(column);
} else if (j === notesIndex) {
event.note = makeString(column, '');
} else if (j === endActionIndex) {
event.endAction = validateEndAction(column);
} else if (j === timerTypeIndex) {
event.timerType = validateTimerType(column);
} else if (j === colourIndex) {
event.colour = makeString(column, '');
} else if (j === user0Index) {
event.user0 = makeString(column, '');
} else if (j === user1Index) {
event.user1 = makeString(column, '');
} else if (j === user2Index) {
event.user2 = makeString(column, '');
} else if (j === user3Index) {
event.user3 = makeString(column, '');
} else if (j === user4Index) {
event.user4 = makeString(column, '');
} else if (j === user5Index) {
event.user5 = makeString(column, '');
} else if (j === user6Index) {
event.user6 = makeString(column, '');
} else if (j === user7Index) {
event.user7 = makeString(column, '');
} else if (j === user8Index) {
event.user8 = makeString(column, '');
} else if (j === user9Index) {
event.user9 = makeString(column, '');
} else {
// 2. if there is no flag, lets see if we know the field type
if (typeof column === 'string') {
const col = column.toLowerCase();
if (handlers[col]) {
handlers[col](j);
}
// else. we don't know how to handle this column
// just ignore it
if (handlers[col]) {
handlers[col](rowIndex, j);
}
// else. we don't know how to handle this column
// just ignore it
}
});
if (Object.keys(event).length > 0) {
// if any data was found, push to array
rundown.push({ ...event, type: SupportedEvent.Event } as OntimeEvent);
}
});
if (Object.keys(event).length > 0) {
// if any data was found, push to array
rundown.push({ ...event, type: SupportedEvent.Event } as OntimeEvent);
}
});
return {
rundown,
project: projectData,
@@ -245,6 +336,8 @@ export const parseExcel = (excelData: unknown[][], options?: Partial<ExcelImport
version: '2.0.0',
},
userFields: customUserFields,
projectMetadata,
rundownMetadata,
};
};
+1 -1
View File
@@ -331,4 +331,4 @@ export const parseUserFields = (data): UserFields => {
}
}
return { ...newUserFields };
};
};
+165
View File
@@ -0,0 +1,165 @@
import { sheets_v4 } from '@googleapis/sheets';
import { millisToString } from 'ontime-utils';
import { OntimeRundownEntry, ProjectData, isOntimeEvent } from 'ontime-types';
/**
*
* @param {number} row - The row number of the cell reference. Row 1 is row number 0.
* @param {number} column - The column number of the cell reference. A is column number 0.
* @returns {string} - Returns a cell reference as a string using A1 Notation
* @author https://www.labnol.org/convert-column-a1-notation-210601
* @example
*
* getA1Notation(2, 4) returns "E3"
* getA1Notation(99, 26) returns "AA100"
*
*/
export function getA1Notation(row: number, column: number): string {
if (row < 0 || column < 0) {
throw new Error('Index can not be less than 0');
}
const a1Notation = [`${row + 1}`];
const totalAlphabets = 'Z'.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
let block = column;
while (block >= 0) {
a1Notation.unshift(String.fromCharCode((block % totalAlphabets) + 'A'.charCodeAt(0)));
block = Math.floor(block / totalAlphabets) - 1;
}
return a1Notation.join('');
}
/**
* @description - creates updateCells request from ontime event
* @param {OntimeRundownEntry} event
* @param {number} index - index of the event
* @param {number} worksheetId
* @param {any} metadata - object with all the cell positions of the title of each attribute
* @returns {sheets_v4.Schema} - list of update requests
*/
export function cellRequestFromEvent(
event: OntimeRundownEntry,
index: number,
worksheetId: number,
metadata,
): sheets_v4.Schema$Request {
const returnRows: sheets_v4.Schema$CellData[] = [];
const tmp = Object.entries(metadata)
.filter(([_, value]) => value !== undefined)
.sort(([_a, a], [_b, b]) => a['col'] - b['col']) as [string, { col: number; row: number }][];
const titleCol = tmp[0][1].col;
for (const [index, e] of tmp.entries()) {
if (index !== 0) {
const prevCol = tmp[index - 1][1].col;
const thisCol = e[1].col;
const diff = thisCol - prevCol;
if (diff > 1) {
const fillArr = new Array<(typeof tmp)[0]>(1).fill(['blank', { row: e[1].row, col: prevCol + 1 }]);
tmp.splice(index, 0, ...fillArr);
}
}
}
tmp.forEach(([key, _]) => {
if (isOntimeEvent(event)) {
if (key === 'blank') {
returnRows.push({});
} else if (key === 'colour') {
returnRows.push({
userEnteredValue: { stringValue: event.colour },
});
} else if (typeof event[key] === 'number') {
returnRows.push({
userEnteredValue: { stringValue: millisToString(event[key], true) },
});
} else if (typeof event[key] === 'string') {
returnRows.push({
userEnteredValue: { stringValue: event[key] },
});
} else if (typeof event[key] === 'boolean') {
returnRows.push({
userEnteredValue: { stringValue: event[key] ? 'x' : '' },
});
} else {
returnRows.push({});
}
}
});
return {
updateCells: {
start: {
sheetId: worksheetId,
rowIndex: index + tmp[0][1]['row'] + 1,
columnIndex: titleCol,
},
fields: 'userEnteredValue',
rows: [
{
values: returnRows,
},
],
},
};
}
/**
* @description - creates updateCells request from ontime event
* @param {ProjectData} projectData
* @param {number} worksheetId
* @param {any} metadata - object with all the cell positions of the title of each attribute
* @returns {sheets_v4.Schema} - list of update requests
*/
export function cellRequenstFromProjectData(
projectData: ProjectData,
worksheetId: number,
metadata,
): sheets_v4.Schema$Request {
const returnRows: sheets_v4.Schema$RowData[] = [];
const tmp = Object.entries(metadata)
.filter(([_, value]) => value !== undefined)
.sort(([_a, a], [_b, b]) => a['col'] - b['col']) as [string, { col: number; row: number }][];
const minRow = Object.values(metadata).reduce(
(accumulator: number, val) => Math.min(accumulator, val['row']),
Number.MAX_VALUE,
) as number;
const minCol = tmp[0][1].col + 1;
for (const [index, e] of tmp.entries()) {
if (index != 0) {
const prevRow = tmp[index - 1][1].row;
const thisRow = e[1].row;
const diff = thisRow - prevRow;
if (diff > 1) {
const fillArr = new Array<(typeof tmp)[0]>(1).fill(['blank', { row: prevRow + 1, col: e[1].col }]);
tmp.splice(index, 0, ...fillArr);
}
}
}
tmp.forEach(([key, _]) => {
if (key == 'blank') {
returnRows.push({});
} else {
returnRows.push({
values: [
{
userEnteredValue: { stringValue: projectData[key] },
},
],
});
}
});
return {
updateCells: {
start: {
sheetId: worksheetId,
rowIndex: minRow,
columnIndex: minCol,
},
fields: 'userEnteredValue',
rows: returnRows,
},
};
}
+380
View File
@@ -0,0 +1,380 @@
import { sheets, sheets_v4 } from '@googleapis/sheets';
import { writeFile } from 'fs/promises';
import { readFileSync } from 'fs';
import { OAuth2Client } from 'google-auth-library';
import http from 'http';
import { DatabaseModel, LogOrigin } from 'ontime-types';
import { join } from 'path';
import { URL } from 'url';
import { logger } from '../classes/Logger.js';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { getAppDataPath } from '../setup.js';
import { ensureDirectory } from './fileManagement.js';
import { cellRequestFromEvent, cellRequenstFromProjectData, getA1Notation } from './sheetUtils.js';
import { parseExcel } from './parser.js';
import { parseProject, parseRundown, parseUserFields } from './parserFunctions.js';
import { ExcelImportMap } from 'ontime-utils';
type ResponseOK = {
data: Partial<DatabaseModel>;
};
class Sheet {
private static client: null | OAuth2Client = null;
private readonly scope = 'https://www.googleapis.com/auth/spreadsheets';
private readonly sheetsFolder: string;
private readonly clientSecretFile: string;
private static clientSecret = null;
private static authUrl: null | string = null;
private authServerTimeout;
private readonly requiredClientKeys = [
'client_id',
'project_id',
'auth_uri',
'token_uri',
'auth_provider_x509_cert_url',
'client_secret',
'redirect_uris',
];
constructor() {
const appDataPath = getAppDataPath();
if (appDataPath === '') {
throw new Error('Sheet: Could not resolve sheet folser');
}
this.sheetsFolder = join(appDataPath, 'sheets');
this.clientSecretFile = join(this.sheetsFolder, 'client_secret.json');
ensureDirectory(this.sheetsFolder);
try {
const secrets = JSON.parse(readFileSync(this.clientSecretFile, 'utf-8'));
const isKeyMissing = this.requiredClientKeys.some((key) => !(key in secrets['installed']));
if (!isKeyMissing) {
Sheet.clientSecret = secrets;
}
} catch (_) {
/* empty - it is ok thet there is no clientSecret */
}
}
/**
* @description SETP 1 - saves secrets object to appdata path as client_secret.json
* @param {object} secrets
* @throws
*/
public async saveClientSecrets(secrets: object) {
Sheet.client = null;
Sheet.authUrl = null;
Sheet.clientSecret = null;
const isKeyMissing = this.requiredClientKeys.some((key) => !(key in secrets['installed']));
if (isKeyMissing) {
throw new Error('Client file is missing some keys');
}
await writeFile(this.clientSecretFile, JSON.stringify(secrets), 'utf-8').catch((err) => {
throw new Error(`Unable to save client file to disk ${err}`);
});
Sheet.clientSecret = secrets;
}
/**
* @description SETP 1 - test that the saved object is pressent
*/
testClientSecret() {
return Sheet.clientSecret !== null;
}
/**
* @description SETP 2 - create server to interact with th OAuth2 request
* @returns {Promise<string | null>} - returns url path serve on success
* @throws
*/
async openAuthServer(): Promise<string | null> {
//TODO: this only works on local networks
// if the server is allready running retun it
if (Sheet.authUrl) {
clearTimeout(this.authServerTimeout);
this.authServerTimeout = setTimeout(
() => {
Sheet.authUrl = null;
server.unref;
},
2 * 60 * 1000,
);
return Sheet.authUrl;
}
// Check that Secret is valid
const keyFile = Sheet.clientSecret;
const keys = keyFile.installed || keyFile.web;
if (!keys.redirect_uris || keys.redirect_uris.length === 0) {
throw new Error('Sheet: Missing redirect URI');
}
const redirectUri = new URL(keys.redirect_uris[0]);
if (redirectUri.hostname !== 'localhost') {
throw new Error('Sheet: Invalid redirect URI');
}
// create an oAuth client to authorize the API call
const client = new OAuth2Client({
clientId: keys.client_id,
clientSecret: keys.client_secret,
});
// start the server that will recive the codes
const server = http.createServer(async (req, res) => {
try {
const serverUrl = new URL(req.url, 'http://localhost:3000');
if (serverUrl.pathname !== redirectUri.pathname) {
res.end('Invalid callback URL');
return;
}
const searchParams = serverUrl.searchParams;
if (searchParams.has('error')) {
res.end('Authorization rejected.');
logger.info(LogOrigin.Server, `Sheet: ${searchParams.get('error')}`);
return;
}
if (!searchParams.has('code')) {
res.end('No authentication code provided.');
logger.info(LogOrigin.Server, `Sheet: Cannot read authentication code`);
return;
}
const code = searchParams.get('code');
const { tokens } = await client.getToken({
code,
redirect_uri: redirectUri.toString(),
});
client.credentials = tokens;
Sheet.client = client;
res.end('Authentication successful! Please close this tab and return to OnTime.');
logger.info(LogOrigin.Server, `Sheet: Authentication successful`);
} catch (e) {
logger.error(LogOrigin.Server, `Sheet: ${e}`);
} finally {
server.close();
}
});
let listenPort = 3000;
if (keyFile.installed) {
// Use emphemeral port if not a web client
listenPort = 0;
} else if (redirectUri.port !== '') {
listenPort = Number(redirectUri.port);
}
//TODO: the server might not start correctly
server.listen(listenPort);
const address = server.address();
if (typeof address !== 'string') {
redirectUri.port = String(address.port);
}
// open the browser to the authorize url to start the workflow
const authorizeUrl = client.generateAuthUrl({
redirect_uri: redirectUri.toString(),
access_type: 'offline',
scope: this.scope,
});
Sheet.authUrl = authorizeUrl;
this.authServerTimeout = setTimeout(
() => {
Sheet.authUrl = null;
server.unref();
},
2 * 60 * 1000,
);
return authorizeUrl;
}
/**
* @description SETP 2 - test that the reciveed OAuth2 is still valid
* @throws
*/
async testAuthentication() {
if (Sheet.client) {
const ref = await Sheet.client.refreshAccessToken();
if (ref.credentials.expiry_date > 10000) {
return true;
} else {
throw new Error('Unable to use access token');
}
} else {
throw new Error('Unable to authenticate');
}
}
/**
* @description SETP 3 - test the given sheet id
* @throws
*/
async testSheetId(id: string) {
const spreadsheets = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.get({
spreadsheetId: id,
includeGridData: false,
});
if (spreadsheets.status != 200) {
throw new Error(spreadsheets.statusText);
}
return { worksheetOptions: spreadsheets.data.sheets.map((i) => i.properties.title) };
}
/**
* @description SETP 4 - test the given worksheet
* @throws
*/
async testWorksheet(id: string, worksheet: string) {
const spreadsheets = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.get({
spreadsheetId: id,
includeGridData: false,
});
if (spreadsheets.status != 200) {
throw new Error(spreadsheets.statusText);
}
const worksheetExist = spreadsheets.data.sheets.find((i) => i.properties.title === worksheet);
if (!worksheetExist) {
throw new Error('Unable to find worksheet');
}
}
/**
* test existence of sheet and worksheet
* @param {string} sheetId - https://docs.google.com/spreadsheets/d/[[spreadsheetId]]/edit#gid=0
* @param {string} worksheet - the name of the worksheet containing ontime data
* @returns {Promise<{worksheetId: number, range: string}>} - id of worksheet and rage of worksheet
* @throws
*/
private async exist(sheetId: string, worksheet: string): Promise<{ worksheetId: number; range: string }> {
const spreadsheets = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.get({
spreadsheetId: sheetId,
});
if (spreadsheets.status === 200) {
const ourWorksheetData = spreadsheets.data.sheets.find((n) => n.properties.title == worksheet);
if (ourWorksheetData !== undefined) {
const endCell = getA1Notation(
ourWorksheetData.properties.gridProperties.rowCount,
ourWorksheetData.properties.gridProperties.columnCount,
);
return { worksheetId: ourWorksheetData.properties.sheetId, range: `${worksheet}!A1:${endCell}` };
}
} else {
throw new Error('Uable to open spreadsheets');
}
}
/**
* @description SETP 5 - Upload the rundown to sheet
* @param {string} id - id of the sheet https://docs.google.com/spreadsheets/d/[[spreadsheetId]]/edit#gid=0
* @param {ExcelImportMap} options
* @throws
*/
public async push(id: string, options: ExcelImportMap) {
const { worksheetId, range } = await this.exist(id, options.worksheet);
const readResponse = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.values.get({
spreadsheetId: id,
valueRenderOption: 'FORMATTED_VALUE',
majorDimension: 'ROWS',
range: range,
});
if (readResponse.status === 200) {
const { rundownMetadata, projectMetadata } = parseExcel(readResponse.data.values, options);
const rundown = DataProvider.getRundown();
const projectData = DataProvider.getProjectData();
const titleRow = Object.values(rundownMetadata)[0]['row'];
const updateRundown = Array<sheets_v4.Schema$Request>();
// we can't delete the last unflozzen row so we create an empty one
updateRundown.push({
insertDimension: {
inheritFromBefore: false,
range: {
dimension: 'ROWS',
startIndex: titleRow + 1,
endIndex: titleRow + 2,
sheetId: worksheetId,
},
},
});
//and delete the rest
updateRundown.push({
deleteDimension: { range: { dimension: 'ROWS', startIndex: titleRow + 2, sheetId: worksheetId } },
});
// insert the lenght of the rundown
updateRundown.push({
insertDimension: {
inheritFromBefore: false,
range: {
dimension: 'ROWS',
startIndex: titleRow + 1,
endIndex: titleRow + rundown.length,
sheetId: worksheetId,
},
},
});
//update the corresponding row with event data
rundown.forEach((entry, index) =>
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, rundownMetadata)),
);
//update project data
updateRundown.push(cellRequenstFromProjectData(projectData, worksheetId, projectMetadata));
const writeResponse = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.batchUpdate({
spreadsheetId: id,
requestBody: {
includeSpreadsheetInResponse: false,
responseRanges: [range],
requests: updateRundown,
},
});
if (writeResponse.status === 200) {
logger.info(LogOrigin.Server, `Sheet: write: ${writeResponse.statusText}`);
} else {
throw new Error(`Sheet: write failed: ${writeResponse.statusText}`);
}
} else {
throw new Error(`Sheet: read failed: ${readResponse.statusText}`);
}
}
/**
* @description STEP 5 - Downpload the rundown from sheet
* @param {string} id - id of the sheet https://docs.google.com/spreadsheets/d/[[spreadsheetId]]/edit#gid=0
* @param {ExcelImportMap} options
* @returns {Promise<Partial<ResponseOK>>}
* @throws
*/
public async pull(id: string, options: ExcelImportMap): Promise<Partial<ResponseOK>> {
const { range } = await this.exist(id, options.worksheet);
const res: Partial<ResponseOK> = {};
const googleResponse = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.values.get({
spreadsheetId: id,
valueRenderOption: 'FORMATTED_VALUE',
majorDimension: 'ROWS',
range,
});
if (googleResponse.status === 200) {
res.data = {};
const dataFromSheet = parseExcel(googleResponse.data.values, options);
res.data.rundown = parseRundown(dataFromSheet);
if (res.data.rundown.length < 1) {
throw new Error(`Sheet: Could not find data to import in the worksheet`);
}
res.data.project = parseProject(dataFromSheet);
res.data.userFields = parseUserFields(dataFromSheet);
return res;
} else {
throw new Error(`Sheet: read failed: ${googleResponse.statusText}`);
}
}
}
export const sheet = new Sheet();
+1 -1
View File
@@ -3,7 +3,7 @@ version: "3"
services:
ontime:
container_name: ontime
image: getontime/ontime:beta_v2
image: getontime/ontime:latest
ports:
- "4001:4001/tcp"
- "8888:8888/udp"
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "2.24.8",
"version": "2.28.11",
"description": "Time keeping for live events",
"keywords": [
"lightdev",
@@ -30,6 +30,8 @@
"build": "turbo run build",
"build:local": "turbo run build:local",
"build:electron": "turbo run build:electron",
"build:docker": "turbo run build:docker",
"build:localdocker": "turbo run build:localdocker",
"dist-win": "turbo run dist-win",
"dist-mac": "turbo run dist-mac",
"dist-linux": "turbo run dist-linux",
+160 -7
View File
@@ -249,6 +249,9 @@ importers:
apps/server:
dependencies:
'@googleapis/sheets':
specifier: ^5.0.5
version: 5.0.5
body-parser:
specifier: ^1.20.0
version: 1.20.1
@@ -270,6 +273,9 @@ importers:
express-validator:
specifier: ^6.14.2
version: 6.14.2
google-auth-library:
specifier: ^9.2.0
version: 9.2.0
got:
specifier: ^14.0.0
version: 14.0.0
@@ -2228,6 +2234,16 @@ packages:
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dev: true
/@googleapis/sheets@5.0.5:
resolution: {integrity: sha512-XMoONmgAJm2jYeTYHX4054VcEkElxlgqmnHvt0wAurzEHoGJLdUHhTAJXGPLgSs4WVMPtgU8HLrmk7/U+Qlw7A==}
engines: {node: '>=12.0.0'}
dependencies:
googleapis-common: 7.0.1
transitivePeerDependencies:
- encoding
- supports-color
dev: false
/@humanwhocodes/config-array@0.11.13:
resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==}
engines: {node: '>=10.10.0'}
@@ -3535,6 +3551,15 @@ packages:
- supports-color
dev: true
/agent-base@7.1.0:
resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==}
engines: {node: '>= 14'}
dependencies:
debug: 4.3.4
transitivePeerDependencies:
- supports-color
dev: false
/ajv-keywords@3.5.2(ajv@6.12.6):
resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==}
peerDependencies:
@@ -3787,13 +3812,16 @@ packages:
/base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
requiresBuild: true
dev: true
/big-integer@1.6.51:
resolution: {integrity: sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==}
engines: {node: '>=0.6'}
dev: true
/bignumber.js@9.1.2:
resolution: {integrity: sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==}
dev: false
/binary-extensions@2.2.0:
resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
engines: {node: '>=8'}
@@ -3896,6 +3924,10 @@ packages:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
dev: true
/buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
dev: false
/buffer-equal@1.0.0:
resolution: {integrity: sha512-tcBWO2Dl4e7Asr9hTGcpVrCe+F7DubpmqWCTbj4FHLmjqO2hIaC383acQubWtRJhdceqs5uBHs6Es+Sk//RKiQ==}
engines: {node: '>=0.4.0'}
@@ -4425,7 +4457,6 @@ packages:
optional: true
dependencies:
ms: 2.1.2
dev: true
/decimal.js@10.4.3:
resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==}
@@ -4654,6 +4685,12 @@ packages:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
dev: true
/ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
dependencies:
safe-buffer: 5.2.1
dev: false
/ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
dev: false
@@ -5300,6 +5337,10 @@ packages:
- supports-color
dev: false
/extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
dev: false
/extract-zip@2.0.1:
resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
engines: {node: '>= 10.17.0'}
@@ -5562,6 +5603,30 @@ packages:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
dev: true
/gaxios@6.1.1:
resolution: {integrity: sha512-bw8smrX+XlAoo9o1JAksBwX+hi/RG15J+NTSxmNPIclKC3ZVK6C2afwY8OSdRvOK0+ZLecUJYtj2MmjOt3Dm0w==}
engines: {node: '>=14'}
dependencies:
extend: 3.0.2
https-proxy-agent: 7.0.2
is-stream: 2.0.1
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
- supports-color
dev: false
/gcp-metadata@6.1.0:
resolution: {integrity: sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==}
engines: {node: '>=14'}
dependencies:
gaxios: 6.1.1
json-bigint: 1.0.0
transitivePeerDependencies:
- encoding
- supports-color
dev: false
/gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
@@ -5693,6 +5758,36 @@ packages:
resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
dev: true
/google-auth-library@9.2.0:
resolution: {integrity: sha512-1oV3p0JhNEhVbj26eF3FAJcv9MXXQt4S0wcvKZaDbl4oHq5V3UJoSbsGZGQNcjoCdhW4kDSwOs11wLlHog3fgQ==}
engines: {node: '>=14'}
dependencies:
base64-js: 1.5.1
ecdsa-sig-formatter: 1.0.11
gaxios: 6.1.1
gcp-metadata: 6.1.0
gtoken: 7.0.1
jws: 4.0.0
transitivePeerDependencies:
- encoding
- supports-color
dev: false
/googleapis-common@7.0.1:
resolution: {integrity: sha512-mgt5zsd7zj5t5QXvDanjWguMdHAcJmmDrF9RkInCecNsyV7S7YtGqm5v2IWONNID88osb7zmx5FtrAP12JfD0w==}
engines: {node: '>=14.0.0'}
dependencies:
extend: 3.0.2
gaxios: 6.1.1
google-auth-library: 9.2.0
qs: 6.11.0
url-template: 2.0.8
uuid: 9.0.1
transitivePeerDependencies:
- encoding
- supports-color
dev: false
/gopd@1.0.1:
resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
dependencies:
@@ -5745,6 +5840,17 @@ packages:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
dev: true
/gtoken@7.0.1:
resolution: {integrity: sha512-KcFVtoP1CVFtQu0aSk3AyAt2og66PFhZAlkUOuWKwzMLoulHXG5W5wE5xAnHb+yl3/wEFoqGW7/cDGMU8igDZQ==}
engines: {node: '>=14.0.0'}
dependencies:
gaxios: 6.1.1
jws: 4.0.0
transitivePeerDependencies:
- encoding
- supports-color
dev: false
/has-bigints@1.0.2:
resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==}
dev: true
@@ -5857,6 +5963,16 @@ packages:
- supports-color
dev: true
/https-proxy-agent@7.0.2:
resolution: {integrity: sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==}
engines: {node: '>= 14'}
dependencies:
agent-base: 7.1.0
debug: 4.3.4
transitivePeerDependencies:
- supports-color
dev: false
/human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
@@ -6135,7 +6251,6 @@ packages:
/is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
dev: true
/is-stream@3.0.0:
resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==}
@@ -6340,6 +6455,12 @@ packages:
hasBin: true
dev: true
/json-bigint@1.0.0:
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
dependencies:
bignumber.js: 9.1.2
dev: false
/json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
@@ -6392,6 +6513,21 @@ packages:
object.assign: 4.1.4
dev: true
/jwa@2.0.0:
resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==}
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
dev: false
/jws@4.0.0:
resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==}
dependencies:
jwa: 2.0.0
safe-buffer: 5.2.1
dev: false
/keyv@4.5.2:
resolution: {integrity: sha512-5MHbFaKn8cNSmVW7BYnijeAVlE4cYA/SVkifVgrh7yotnfhKmjuXpDKjrABLnT0SfHWV21P8ow07OGfRrNDg8g==}
dependencies:
@@ -6744,7 +6880,6 @@ packages:
/ms@2.1.2:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
dev: true
/ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -6801,6 +6936,18 @@ packages:
whatwg-url: 5.0.0
dev: true
/node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
dependencies:
whatwg-url: 5.0.0
dev: false
/node-osc@9.0.2:
resolution: {integrity: sha512-q+VQL7DMWRL5+yvzRlWVig8BD9raotLs6onHU4e8MaFgxmYuIwcXhsvQeyUZFiKP6y/qGUXU6K0T99gVmISwmA==}
engines: {node: ^18.17.0 || >=20.5.0}
@@ -8203,7 +8350,6 @@ packages:
/tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
dev: true
/tr46@3.0.0:
resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==}
@@ -8507,6 +8653,10 @@ packages:
requires-port: 1.0.0
dev: true
/url-template@2.0.8:
resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==}
dev: false
/use-callback-ref@1.3.0(@types/react@18.0.26)(react@18.2.0):
resolution: {integrity: sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==}
engines: {node: '>=10'}
@@ -8559,6 +8709,11 @@ packages:
engines: {node: '>= 0.4.0'}
dev: false
/uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
dev: false
/v8-compile-cache-lib@3.0.1:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
dev: true
@@ -8911,7 +9066,6 @@ packages:
/webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
dev: true
/webidl-conversions@7.0.0:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
@@ -8957,7 +9111,6 @@ packages:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
dev: true
/which-boxed-primitive@1.0.2:
resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==}
+1
View File
@@ -26,6 +26,7 @@
"build:local": {},
"build:electron": {},
"build:docker": {},
"build:localdocker": {},
"e2e": {
"dependsOn": ["^build"]
},