mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-03 06:28:01 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1ae407996 | |||
| 467b595375 | |||
| 965a7092d9 | |||
| 661337fef9 | |||
| 71092afc02 | |||
| b1ba47a80f | |||
| 9611722a6d | |||
| 92cefa1331 | |||
| c0fb865959 | |||
| 068ec800f4 | |||
| 1a8b35a5ea | |||
| 40bede200c | |||
| f3e2944c55 | |||
| 09f2874784 | |||
| 8afffb9870 | |||
| 33575c551a | |||
| 3173fb57fc | |||
| 7d33019f53 | |||
| 6526b32d4c | |||
| d6aef5ea31 | |||
| 8756b396cc | |||
| 5a2f711eab | |||
| a56e2b1c1c | |||
| da829dc09f |
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "2.21.3",
|
||||
"version": "2.28.16",
|
||||
"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';
|
||||
|
||||
@@ -3,8 +3,6 @@ import {
|
||||
Alias,
|
||||
DatabaseModel,
|
||||
GetInfo,
|
||||
GoogleSheet,
|
||||
GoogleSheetState,
|
||||
HttpSettings,
|
||||
OntimeRundown,
|
||||
OSCSettings,
|
||||
@@ -249,14 +247,13 @@ export async function postNew(initialData: Partial<ProjectData>) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @description sheet Client File
|
||||
* @return {Promise}
|
||||
* @description STEP 1
|
||||
*/
|
||||
export const uploadSheetClientFile = async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('userFile', file);
|
||||
const res = await axios
|
||||
.post(`${ontimeURL}/sheet-clientsecrect`, formData, {
|
||||
.post(`${ontimeURL}/sheet/clientsecret`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
@@ -265,44 +262,59 @@ export const uploadSheetClientFile = async (file: File) => {
|
||||
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 res = await axios.get(`${ontimeURL}/sheet-authurl`);
|
||||
return res.data;
|
||||
const response = await axios.get(`${ontimeURL}/sheet/authentication/url`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const postPreviewSheet = async () => {
|
||||
const response = await axios.post(`${ontimeURL}/sheet-preview`);
|
||||
return response.data.data;
|
||||
/**
|
||||
* @description STEP 2 test
|
||||
*/
|
||||
export const getAuthentication = async () => {
|
||||
const response = await axios.get(`${ontimeURL}/sheet/authentication`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const postPushSheet = async () => {
|
||||
const response = await axios.post(`${ontimeURL}/sheet-push`);
|
||||
/**
|
||||
* @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 HTTP request to retrieve google sheets settings
|
||||
* @return {Promise}
|
||||
* @description STEP 5
|
||||
*/
|
||||
export async function getSheetSettings(): Promise<GoogleSheet> {
|
||||
const res = await axios.get(`${ontimeURL}/sheet-settings`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to mutate google sheets settings
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function postSheetSettings(data: GoogleSheet): Promise<GoogleSheet> {
|
||||
const res = await axios.post(`${ontimeURL}/sheet-settings`, data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to retrieve google sheets state
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function getSheetstate(): Promise<GoogleSheetState> {
|
||||
const res = await axios.get(`${ontimeURL}/sheet-state`);
|
||||
return res.data;
|
||||
}
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
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';
|
||||
import { calculateDuration, dayInMs, 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 { cx } from '../../../common/utils/styleUtils';
|
||||
import { TimeEntryField, validateEntry } from '../../../common/utils/timesManager';
|
||||
|
||||
import style from '../EventEditor.module.scss';
|
||||
|
||||
@@ -29,21 +28,13 @@ 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 };
|
||||
switch (field) {
|
||||
case 'durationOverride': {
|
||||
// duration defines timeEnd
|
||||
newEventData.duration = value as number;
|
||||
newEventData.timeEnd = timeStart + (value as number);
|
||||
newEventData.timeEnd = timeStart + ((value as number) % dayInMs);
|
||||
break;
|
||||
}
|
||||
case 'timeStart': {
|
||||
@@ -87,11 +78,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 +89,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 +100,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}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { VStack } from '@chakra-ui/react';
|
||||
import { IoCalendarOutline } from '@react-icons/all-files/io5/IoCalendarOutline';
|
||||
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';
|
||||
@@ -182,7 +183,7 @@ const MenuBar = (props: MenuBarProps) => {
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
isDisabled={appMode === AppMode.Run}
|
||||
icon={<IoCalendarOutline />}
|
||||
icon={isSheetsOpen ? <IoCloud /> : <IoCloudOutline />}
|
||||
className={isSheetsOpen ? style.open : ''}
|
||||
clickHandler={onSheetsOpen}
|
||||
tooltip='Sheets'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
Button,
|
||||
HStack,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
@@ -9,29 +14,33 @@ import {
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Select,
|
||||
} from '@chakra-ui/react';
|
||||
import { IoArrowDownCircleOutline } from '@react-icons/all-files/io5/IoArrowDownCircleOutline';
|
||||
import { IoArrowUpCircleOutline } from '@react-icons/all-files/io5/IoArrowUpCircleOutline';
|
||||
import { IoCheckmarkCircleOutline } from '@react-icons/all-files/io5/IoCheckmarkCircleOutline';
|
||||
import { IoCloseCircleOutline } from '@react-icons/all-files/io5/IoCloseCircleOutline';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { GoogleSheetState, OntimeRundown, ProjectData, UserFields } from 'ontime-types';
|
||||
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,
|
||||
getSheetSettings,
|
||||
getSheetstate,
|
||||
patchData,
|
||||
postId,
|
||||
postPreviewSheet,
|
||||
postPushSheet,
|
||||
postSheetSettings,
|
||||
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;
|
||||
@@ -41,18 +50,37 @@ interface SheetsModalProps {
|
||||
export default function SheetsModal(props: SheetsModalProps) {
|
||||
const { isOpen, onClose } = props;
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
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 [sheetState, setSheetState] = useState<GoogleSheetState>({ auth: false, id: false, worksheet: false });
|
||||
const [id, setSheetId] = useState('');
|
||||
const [worksheet, setWorksheet] = useState('');
|
||||
const [worksheetOptions, setWorksheetOptions] = useState<string[]>([]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [direction, setDirection] = useState('none');
|
||||
const excelFileOptions = useRef<ExcelImportMap>(defaultExcelImportMap);
|
||||
|
||||
const sheetid = useRef<HTMLInputElement>(null);
|
||||
const worksheet = useRef<HTMLInputElement>(null);
|
||||
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);
|
||||
@@ -60,73 +88,162 @@ export default function SheetsModal(props: SheetsModalProps) {
|
||||
setUserFields(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
//SETP-1 Upload Client ID
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFile = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = event?.target?.files?.[0];
|
||||
if (selectedFile) {
|
||||
await uploadSheetClientFile(selectedFile).catch((err) => {
|
||||
console.error(err); //TODO: how to show this to the user
|
||||
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: '' },
|
||||
});
|
||||
_onChange();
|
||||
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 _onChange = async () => {
|
||||
setSheetState(await getSheetstate());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getSheetSettings().then((data) => {
|
||||
if (sheetid.current?.value != data.id || worksheet.current?.value != data.worksheet) {
|
||||
_onChange();
|
||||
}
|
||||
if (sheetid.current) {
|
||||
sheetid.current.value = data.id;
|
||||
}
|
||||
if (worksheet.current) {
|
||||
worksheet.current.value = data.worksheet;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const handelSave = () => {
|
||||
postSheetSettings({ id: sheetid.current?.value ?? '', worksheet: worksheet.current?.value ?? '' }).then((data) => {
|
||||
_onChange();
|
||||
if (sheetid.current) {
|
||||
sheetid.current.value = data.id;
|
||||
}
|
||||
if (worksheet.current) {
|
||||
worksheet.current.value = data.worksheet;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleAuthenticate = () => {
|
||||
getSheetsAuthUrl().then((data) => {
|
||||
if (data != 'bad') {
|
||||
window.open(data, '_blank', 'noreferrer');
|
||||
//TODO: can we detect when this window is closed
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handlePullData = () => {
|
||||
postPreviewSheet().then((data) => {
|
||||
setProject(data.project);
|
||||
setRundown(data.rundown);
|
||||
setUserFields(data.userFields);
|
||||
});
|
||||
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();
|
||||
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 () => {
|
||||
// this step is currently only used for excel files, after preview
|
||||
if (rundown && userFields && project) {
|
||||
let doClose = false;
|
||||
try {
|
||||
@@ -140,8 +257,7 @@ export default function SheetsModal(props: SheetsModalProps) {
|
||||
doClose = true;
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
console.log(message);
|
||||
// setErrors(`Failed applying changes ${message}`);
|
||||
console.error(message);
|
||||
} finally {
|
||||
if (doClose) {
|
||||
handleClose();
|
||||
@@ -163,93 +279,188 @@ export default function SheetsModal(props: SheetsModalProps) {
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Sheets!</ModalHeader>
|
||||
<ModalHeader>Rundown from sheets (experimental)</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{rundown && (
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
{!rundown && (
|
||||
<>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
type='file'
|
||||
onChange={handleFile}
|
||||
accept='.json'
|
||||
data-testid='file-input'
|
||||
/>
|
||||
<div>Need to add some help here</div>
|
||||
<div>
|
||||
<Button onClick={handleClick}>Upload Client Secrect</Button>
|
||||
</div>
|
||||
<Button variant='ontime-filled' padding='0 2em' onClick={handleAuthenticate}>
|
||||
Authenticate
|
||||
</Button>
|
||||
{sheetState.auth ? <div>You are authenticated</div> : <div>You are not authenticated</div>}
|
||||
<div>
|
||||
<label htmlFor='sheetid'>Sheet ID </label>
|
||||
<Input
|
||||
type='text'
|
||||
ref={sheetid}
|
||||
id='sheetid'
|
||||
width='240px'
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
variant='ontime-filled-on-light'
|
||||
/>
|
||||
{sheetState.id ? <IoCheckmarkCircleOutline /> : <IoCloseCircleOutline />}
|
||||
<br />
|
||||
<label htmlFor='worksheet'>Worksheet </label>
|
||||
<Input
|
||||
type='text'
|
||||
ref={worksheet}
|
||||
id='worksheet'
|
||||
width='240px'
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
variant='ontime-filled-on-light'
|
||||
/>
|
||||
{sheetState.worksheet ? <IoCheckmarkCircleOutline /> : <IoCloseCircleOutline />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
{!rundown && (
|
||||
<div>
|
||||
{rundown ? (
|
||||
<div style={{ display: 'flex', gap: '1em' }}>
|
||||
<Button
|
||||
variant='ontime-subtle-on-light'
|
||||
padding='0 2em'
|
||||
onClick={handlePullData}
|
||||
rightIcon={<IoArrowDownCircleOutline />}
|
||||
onClick={() => {
|
||||
setRundown(null);
|
||||
setDirection('none');
|
||||
}}
|
||||
variant='ontime-ghost-on-light'
|
||||
>
|
||||
Pull data
|
||||
Go Back
|
||||
</Button>
|
||||
<Button
|
||||
variant='ontime-subtle-on-light'
|
||||
padding='0 2em'
|
||||
onClick={handlePushData}
|
||||
rightIcon={<IoArrowUpCircleOutline />}
|
||||
>
|
||||
Push data
|
||||
<Button variant='ontime-filled' padding='0 2em' onClick={handleFinalise}>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button variant='ontime-ghost-on-light'>Reset</Button>
|
||||
{!rundown && (
|
||||
<Button variant='ontime-filled' padding='0 2em' onClick={handelSave}>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
{rundown && (
|
||||
<Button variant='ontime-filled' padding='0 2em' onClick={handleFinalise}>
|
||||
Import
|
||||
</Button>
|
||||
) : 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>
|
||||
|
||||
@@ -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 { calculateDuration, dayInMs, 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,15 +23,13 @@ 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) {
|
||||
case 'durationOverride': {
|
||||
// duration defines timeEnd
|
||||
newEventData.duration = value;
|
||||
newEventData.timeEnd = timeStart + value;
|
||||
newEventData.timeEnd = timeStart + ((value as number) % dayInMs);
|
||||
break;
|
||||
}
|
||||
case 'timeStart': {
|
||||
@@ -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}>
|
||||
|
||||
@@ -126,10 +126,11 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
const hideEndMessage = searchParams.get('hideendmessage');
|
||||
userOptions.hideEndMessage = isStringBoolean(hideEndMessage);
|
||||
|
||||
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
|
||||
|
||||
const showOverlay = pres.text !== '' && pres.visible;
|
||||
const isPlaying = time.playback !== Playback.Pause;
|
||||
const isNegative =
|
||||
(time.current ?? 0) < 0 && time.timerType !== TimerType.Clock && time.timerType !== TimerType.CountUp;
|
||||
const isNegative = (time.current ?? 0) < 0 && !timerIsTimeOfDay && time.timerType !== TimerType.CountUp;
|
||||
const showEndMessage = (time.current ?? 0) < 0 && viewSettings.endMessage && !hideEndMessage;
|
||||
const finished = time.playback === Playback.Play && (time.current ?? 0) < 0 && time.startedAt;
|
||||
const showFinished = finished && !userOptions?.hideOvertime && (time.timerType !== TimerType.Clock || showEndMessage);
|
||||
@@ -140,13 +141,9 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
const showBlinking = pres.timerBlink;
|
||||
const showBlackout = pres.timerBlackout;
|
||||
|
||||
const timerColor = userOptions.textColour
|
||||
? userOptions.textColour
|
||||
: showProgress && showDanger
|
||||
? viewSettings.dangerColor
|
||||
: showProgress && showWarning
|
||||
? viewSettings.warningColor
|
||||
: viewSettings.normalColor;
|
||||
let timerColor = viewSettings.normalColor;
|
||||
if (!timerIsTimeOfDay && showProgress && showDanger) timerColor = viewSettings.dangerColor;
|
||||
if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor;
|
||||
|
||||
const stageTimer = getTimerByType(time);
|
||||
let display = formatTimerDisplay(stageTimer);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,8 +90,9 @@ export default function Timer(props: TimerProps) {
|
||||
const showOverlay = pres.text !== '' && pres.visible;
|
||||
const isPlaying = time.playback !== Playback.Pause;
|
||||
|
||||
const isNegative =
|
||||
(time.current ?? 0) < 0 && time.timerType !== TimerType.Clock && time.timerType !== TimerType.CountUp;
|
||||
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
|
||||
|
||||
const isNegative = (time.current ?? 0) < 0 && !timerIsTimeOfDay && time.timerType !== TimerType.CountUp;
|
||||
const finished = time.playback === Playback.Play && (time.current ?? 0) < 0 && time.startedAt;
|
||||
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
|
||||
|
||||
@@ -105,12 +106,9 @@ export default function Timer(props: TimerProps) {
|
||||
const showClock = time.timerType !== TimerType.Clock;
|
||||
const showExternal = external.visible && external.text;
|
||||
|
||||
const timerColor =
|
||||
showProgress && showDanger
|
||||
? viewSettings.dangerColor
|
||||
: showProgress && showWarning
|
||||
? viewSettings.warningColor
|
||||
: viewSettings.normalColor;
|
||||
let timerColor = viewSettings.normalColor;
|
||||
if (!timerIsTimeOfDay && showProgress && showDanger) timerColor = viewSettings.dangerColor;
|
||||
if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor;
|
||||
|
||||
const stageTimer = getTimerByType(time);
|
||||
let display = formatTimerDisplay(stageTimer);
|
||||
@@ -173,7 +171,7 @@ export default function Timer(props: TimerProps) {
|
||||
{!userOptions.hideProgress && (
|
||||
<MultiPartProgressBar
|
||||
className={isPlaying ? 'progress-container' : 'progress-container progress-container--paused'}
|
||||
now={time.current}
|
||||
now={timerIsTimeOfDay ? null : time.current}
|
||||
complete={totalTime}
|
||||
normalColor={viewSettings.normalColor}
|
||||
warning={viewSettings.warningThreshold}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -138,11 +138,6 @@ function createWindow() {
|
||||
});
|
||||
|
||||
win.setMenu(null);
|
||||
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
}
|
||||
|
||||
app.disableHardwareAcceleration();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "2.21.3",
|
||||
"version": "2.28.16",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "2.21.3",
|
||||
"version": "2.28.16",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
@@ -55,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
@@ -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();
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
UserFields,
|
||||
Alias,
|
||||
Settings,
|
||||
GoogleSheet,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { data, db } from '../../modules/loadDb.js';
|
||||
@@ -59,15 +58,6 @@ export class DataProvider {
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
static getGoogleSheet() {
|
||||
return data.googleSheet;
|
||||
}
|
||||
|
||||
static async setGoogleSheet(newData: GoogleSheet) {
|
||||
data.googleSheet = { ...newData };
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
static getOsc() {
|
||||
return data.osc;
|
||||
}
|
||||
|
||||
@@ -6,13 +6,12 @@ import { DatabaseModel } from 'ontime-types';
|
||||
* @param {object} newData
|
||||
*/
|
||||
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>) {
|
||||
const { rundown, project, settings, googleSheet, viewSettings, osc, aliases, userFields } = newData || {};
|
||||
const { rundown, project, settings, viewSettings, osc, aliases, userFields } = newData || {};
|
||||
return {
|
||||
...existing,
|
||||
rundown: rundown ?? existing.rundown,
|
||||
project: { ...existing.project, ...project },
|
||||
settings: { ...existing.settings, ...settings },
|
||||
googleSheet: { ...existing.googleSheet, ...googleSheet },
|
||||
viewSettings: { ...existing.viewSettings, ...viewSettings },
|
||||
aliases: aliases ?? existing.aliases,
|
||||
userFields: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alias, DatabaseModel, GoogleSheet, OntimeRundown, Settings } from 'ontime-types';
|
||||
import { Alias, DatabaseModel, OntimeRundown, Settings } from 'ontime-types';
|
||||
import { safeMerge } from '../DataProvider.utils.js';
|
||||
|
||||
describe('safeMerge', () => {
|
||||
@@ -21,10 +21,6 @@ describe('safeMerge', () => {
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
},
|
||||
googleSheet: {
|
||||
worksheet: '1',
|
||||
id: '2',
|
||||
},
|
||||
viewSettings: {
|
||||
overrideStyles: false,
|
||||
endMessage: 'existing endMessage',
|
||||
@@ -101,21 +97,6 @@ describe('safeMerge', () => {
|
||||
language: 'pt',
|
||||
});
|
||||
});
|
||||
|
||||
it('merges the google sheet key', () => {
|
||||
const newData = {
|
||||
googleSheet: {
|
||||
id: '4',
|
||||
worksheet: '5',
|
||||
} as GoogleSheet,
|
||||
};
|
||||
const mergedData = safeMerge(existing, newData);
|
||||
expect(mergedData.googleSheet).toEqual({
|
||||
id: '4',
|
||||
worksheet: '5',
|
||||
});
|
||||
});
|
||||
|
||||
it('merges the osc key', () => {
|
||||
const newData = {
|
||||
osc: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,7 +20,7 @@ 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';
|
||||
import { sheet } from '../utils/sheetsAuth.js';
|
||||
|
||||
// Create controller for GET request to '/ontime/poll'
|
||||
// Returns data for current state
|
||||
@@ -119,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({
|
||||
@@ -458,45 +458,19 @@ export const postNew: RequestHandler = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
//SHEET Functions
|
||||
/**
|
||||
* downloads and parses an sheet
|
||||
* @description SETP-1 POST Client Secrect
|
||||
* @returns parsed result
|
||||
*/
|
||||
export async function previewSheet(req, res) {
|
||||
try {
|
||||
const data = await Sheet.pull();
|
||||
res.status(200).send(data);
|
||||
} catch (error) {
|
||||
res.status(500).send({ message: error.toString() });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* downloads and parses an sheet
|
||||
* @returns parsed result
|
||||
*/
|
||||
export async function pushSheet(req, res) {
|
||||
try {
|
||||
await Sheet.push();
|
||||
res.status(200).send('ok');
|
||||
} catch (error) {
|
||||
res.status(500).send({ message: error.toString() });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* uploads Client secrets file
|
||||
* @returns parsed result
|
||||
*/
|
||||
export async function uploadGoogleSheetClientFile(req, res) {
|
||||
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);
|
||||
await sheet.saveClientSecrets(client);
|
||||
res.status(200).send('OK');
|
||||
} catch (error) {
|
||||
res.status(500).send({ message: error.toString() });
|
||||
@@ -507,51 +481,98 @@ export async function uploadGoogleSheetClientFile(req, res) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns link to sheet auth url
|
||||
* @description SETP-1 GET Client Secrect status
|
||||
*/
|
||||
export async function sheetAuthUrl(req, res) {
|
||||
const successful = await Sheet.openAuthServer();
|
||||
if (successful === false) {
|
||||
res.status(500).send('bad');
|
||||
} else {
|
||||
res.status(200).send(successful);
|
||||
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 Get google sheet Settings
|
||||
* @method GET
|
||||
* @description SETP-2 GET sheet authentication status
|
||||
*/
|
||||
export const getGoogleSheetSettings = async (req, res) => {
|
||||
const sheet = await DataProvider.getGoogleSheet();
|
||||
res.status(200).send(sheet);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Change view Settings
|
||||
* @method POST
|
||||
*/
|
||||
export const postGoogleSheetSettings = async (req, res) => {
|
||||
if (failEmptyObjects(req.body, res)) {
|
||||
return;
|
||||
}
|
||||
|
||||
export const getAuthentication = async (req, res) => {
|
||||
try {
|
||||
const newData = {
|
||||
id: req.body.id,
|
||||
worksheet: req.body.worksheet,
|
||||
};
|
||||
await DataProvider.setGoogleSheet(newData);
|
||||
res.status(200).send(newData);
|
||||
await sheet.testAuthentication();
|
||||
res.status(200).send();
|
||||
} catch (error) {
|
||||
res.status(400).send({ message: error.toString() });
|
||||
res.status(500).send({ message: error.toString() });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Get google sheet state
|
||||
* @method GET
|
||||
* @description SETP-3 POST sheet id
|
||||
* @returns list of worksheets
|
||||
*/
|
||||
export const getGoogleSheetState = async (req, res) => {
|
||||
res.status(200).send(await Sheet.getSheetState());
|
||||
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() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,11 +153,8 @@ export const validatePatchProjectFile = [
|
||||
},
|
||||
];
|
||||
|
||||
//TODO: is thise correct
|
||||
export const validateSheetPreview = [
|
||||
body('sheetid').isString().optional({ nullable: false }),
|
||||
body('worksheet').isString().optional({ nullable: false }),
|
||||
body('options').isObject().optional({ nullable: true }),
|
||||
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() });
|
||||
@@ -165,9 +162,19 @@ export const validateSheetPreview = [
|
||||
},
|
||||
];
|
||||
|
||||
export const validateGoogleSheetSettings = [
|
||||
body('id').isString().optional({ nullable: false }),
|
||||
body('worksheet').isString().optional({ nullable: false }),
|
||||
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() });
|
||||
|
||||
Vendored
+67
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -20,10 +20,6 @@ export const dbModel: DatabaseModel = {
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
},
|
||||
googleSheet: {
|
||||
worksheet: '',
|
||||
id: '',
|
||||
},
|
||||
viewSettings: {
|
||||
overrideStyles: false,
|
||||
normalColor: '#ffffffcc',
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -21,26 +21,27 @@ import {
|
||||
postViewSettings,
|
||||
previewExcel,
|
||||
postHTTP,
|
||||
sheetAuthUrl,
|
||||
uploadGoogleSheetClientFile,
|
||||
previewSheet,
|
||||
getAuthenticationUrl,
|
||||
uploadSheetClientFile as uploadClientSecret,
|
||||
pullSheet,
|
||||
pushSheet,
|
||||
getGoogleSheetSettings,
|
||||
postGoogleSheetSettings,
|
||||
getGoogleSheetState,
|
||||
postId,
|
||||
getAuthentication,
|
||||
getClientSecrect as getClientSecret,
|
||||
} from '../controllers/ontimeController.js';
|
||||
|
||||
import {
|
||||
validateAliases,
|
||||
validateGoogleSheetSettings,
|
||||
validateOSC,
|
||||
validatePatchProjectFile,
|
||||
validateSettings,
|
||||
validateSheetPreview,
|
||||
validateUserFields,
|
||||
viewValidator,
|
||||
validateHTTP,
|
||||
validateOscSubscription,
|
||||
validateSheetid,
|
||||
validateWorksheet,
|
||||
validateSheetOptions,
|
||||
} from '../controllers/ontimeController.validate.js';
|
||||
import { projectSanitiser } from '../controllers/projectController.validate.js';
|
||||
|
||||
@@ -106,23 +107,22 @@ router.post('/http', validateHTTP, postHTTP);
|
||||
// create route between controller and '/ontime/new' endpoint
|
||||
router.post('/new', projectSanitiser, postNew);
|
||||
|
||||
// create route between controller and '/ontime/sheet-client' endpoint
|
||||
router.post('/sheet-clientsecrect', uploadFile, uploadGoogleSheetClientFile);
|
||||
//SETP-1
|
||||
router.post('/sheet/clientsecret', uploadFile, uploadClientSecret);
|
||||
router.get('/sheet/clientsecret', uploadFile, getClientSecret);
|
||||
|
||||
// create route between controller and '/ontime/sheet-authstatus' endpoint
|
||||
router.get('/sheet-authurl', sheetAuthUrl);
|
||||
//SETP-2
|
||||
router.get('/sheet/authentication/url', getAuthenticationUrl);
|
||||
router.get('/sheet/authentication', getAuthentication);
|
||||
|
||||
// create route between controller and '/ontime/preview-sheet' endpoint
|
||||
router.post('/sheet-preview', validateSheetPreview, previewSheet);
|
||||
//STEP-3
|
||||
router.post('/sheet/id', validateSheetid, postId);
|
||||
|
||||
// create route between controller and '/ontime/preview-sheet' endpoint
|
||||
router.post('/sheet-push', pushSheet);
|
||||
//STEP-4
|
||||
router.post('/sheet/worksheet', validateWorksheet, postId);
|
||||
|
||||
// create route between controller and '/ontime/sheet-settings' endpoint
|
||||
router.get('/sheet-settings', getGoogleSheetSettings);
|
||||
//STEP-5 download and generate preview
|
||||
router.post('/sheet/pull', validateSheetOptions, pullSheet);
|
||||
|
||||
// create route between controller and '/ontime/sheet-settings' endpoint
|
||||
router.post('/sheet-settings', validateGoogleSheetSettings, postGoogleSheetSettings);
|
||||
|
||||
// create route between controller and '/ontime/sheet-state' endpoint
|
||||
router.get('/sheet-state', getGoogleSheetState);
|
||||
//STEP-5 upload
|
||||
router.post('/sheet-push', validateSheetOptions, pushSheet);
|
||||
|
||||
@@ -528,6 +528,76 @@ describe('test that roll behaviour multi day event edge cases', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// test getRollTimers() on issue #757
|
||||
describe('it handles timeEnd over day', () => {
|
||||
it('ignores events with timeEnd larger than a day', () => {
|
||||
const testRundown = [
|
||||
{
|
||||
title: 'Setup',
|
||||
subtitle: '',
|
||||
presenter: '',
|
||||
note: 'BMA, Nebelfluid, Shooter, Akkus, UHF11, Getränke, Kaffee, Strom, ELA, Text für Holger',
|
||||
endAction: 'play-next',
|
||||
timerType: 'count-down',
|
||||
timeStart: 66600000,
|
||||
timeEnd: 68400000,
|
||||
duration: 1800000,
|
||||
isPublic: false,
|
||||
skip: false,
|
||||
colour: '#2fa9e5',
|
||||
user0: '',
|
||||
user1: '',
|
||||
user2: '',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: 'event',
|
||||
revision: 0,
|
||||
cue: 'PRE',
|
||||
id: 'b2f8d',
|
||||
},
|
||||
{
|
||||
title: 'Künstliche Intelligenz',
|
||||
subtitle: ' -> Vorstellung Maske',
|
||||
presenter: 'Engel und Teufel',
|
||||
note: 'Melli Kleben! Sofa',
|
||||
endAction: 'play-next',
|
||||
timerType: 'count-down',
|
||||
timeStart: 86100000,
|
||||
// timeEnd: 1020000, <--- this would have been equivalent
|
||||
timeEnd: 87420000,
|
||||
duration: 1320000,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
colour: '#a8ec31',
|
||||
user0: 'UHF1 Melli (Korsett)',
|
||||
user1: 'UHF2 Reinhold (unter Flügel)',
|
||||
user2: 'UHF3 Oli (Sport-Unterhose Rechts)',
|
||||
user3: '',
|
||||
user4: '',
|
||||
user5: '',
|
||||
user6: '',
|
||||
user7: '',
|
||||
user8: '',
|
||||
user9: '',
|
||||
type: 'event',
|
||||
revision: 0,
|
||||
cue: '16',
|
||||
id: '8b970',
|
||||
},
|
||||
];
|
||||
|
||||
const timeNow = 64488675; // 17:55-something
|
||||
|
||||
const timers = getRollTimers(testRundown as OntimeEvent[], timeNow);
|
||||
expect(timers.currentEvent).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// test normaliseEndTime() on issue #58
|
||||
test('test typical scenarios', () => {
|
||||
const t1 = {
|
||||
|
||||
@@ -79,7 +79,8 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number) => {
|
||||
const normalEnd = normaliseEndTime(event.timeStart, event.timeEnd);
|
||||
|
||||
const hasNotEnded = normalEnd > timeNow;
|
||||
const isFromDayBefore = normalEnd > dayInMs && timeNow < event.timeEnd;
|
||||
// TODO: we will likely want a better solution than the modulus here
|
||||
const isFromDayBefore = normalEnd > dayInMs && timeNow < event.timeEnd % dayInMs;
|
||||
const hasStarted = isFromDayBefore || timeNow >= event.timeStart;
|
||||
|
||||
if (normalEnd <= timeNow) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
+8
-8
@@ -1,5 +1,5 @@
|
||||
import { millisToString } from 'ontime-utils';
|
||||
import { getA1Notation, cellRequenstFromEvent, cellRequenstFromProjectData } from '../googleSheetUtils.js';
|
||||
import { getA1Notation, cellRequestFromEvent, cellRequenstFromProjectData } from '../sheetUtils.js';
|
||||
import { EndAction, OntimeRundownEntry, ProjectData, SupportedEvent, TimerType } from 'ontime-types';
|
||||
|
||||
describe('getA1Notation()', () => {
|
||||
@@ -75,7 +75,7 @@ describe('cellRequenstFromEvent()', () => {
|
||||
revision: { row: 1, col: 38 },
|
||||
id: { row: 1, col: 39 },
|
||||
};
|
||||
const result = cellRequenstFromEvent(event, 1, 1234, metadata);
|
||||
const result = cellRequestFromEvent(event, 1, 1234, metadata);
|
||||
expect(result.updateCells.rows[0].values[5].userEnteredValue.stringValue).toStrictEqual(event.note);
|
||||
});
|
||||
|
||||
@@ -136,7 +136,7 @@ describe('cellRequenstFromEvent()', () => {
|
||||
revision: { row: 1, col: 38 },
|
||||
id: { row: 1, col: 39 },
|
||||
};
|
||||
const result = cellRequenstFromEvent(event, 1, 1234, metadata).updateCells.rows[0].values[10].userEnteredValue
|
||||
const result = cellRequestFromEvent(event, 1, 1234, metadata).updateCells.rows[0].values[10].userEnteredValue
|
||||
.stringValue;
|
||||
expect(result).toStrictEqual(millisToString(event.duration));
|
||||
});
|
||||
@@ -198,7 +198,7 @@ describe('cellRequenstFromEvent()', () => {
|
||||
revision: { row: 1, col: 38 },
|
||||
id: { row: 1, col: 39 },
|
||||
};
|
||||
const result = cellRequenstFromEvent(event, 1, 1234, metadata);
|
||||
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('');
|
||||
});
|
||||
@@ -238,7 +238,7 @@ describe('cellRequenstFromEvent()', () => {
|
||||
subtitle: { row: 1, col: 10 },
|
||||
user0: { row: 1, col: 16 },
|
||||
};
|
||||
const result = cellRequenstFromEvent(event, 1, 1234, metadata);
|
||||
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);
|
||||
@@ -279,7 +279,7 @@ describe('cellRequenstFromEvent()', () => {
|
||||
subtitle: { row: 1, col: 10 },
|
||||
user0: { row: 1, col: 16 },
|
||||
};
|
||||
const result = cellRequenstFromEvent(event, 1, 1234, metadata);
|
||||
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);
|
||||
@@ -320,9 +320,9 @@ describe('cellRequenstFromEvent()', () => {
|
||||
subtitle: { row: 1, col: 10 },
|
||||
user0: { row: 10, col: 16 },
|
||||
};
|
||||
const result1 = cellRequenstFromEvent(event, 1, 1234, metadata);
|
||||
const result1 = cellRequestFromEvent(event, 1, 1234, metadata);
|
||||
expect(result1.updateCells.start.sheetId).toStrictEqual(1234);
|
||||
const result2 = cellRequenstFromEvent(event, 10, 1234, metadata);
|
||||
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');
|
||||
@@ -34,9 +34,9 @@ import {
|
||||
parseSettings,
|
||||
parseUserFields,
|
||||
parseViewSettings,
|
||||
parseGoogleSheet,
|
||||
} 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';
|
||||
@@ -51,6 +51,9 @@ export const parseExcel = (excelData: unknown[][], options?: Partial<ExcelImport
|
||||
const projectMetadata = {};
|
||||
const rundownMetadata = {};
|
||||
const importMap: ExcelImportMap = { ...defaultExcelImportMap, ...options };
|
||||
for (const [key, value] of Object.entries(importMap)) {
|
||||
importMap[key] = value.toLocaleLowerCase();
|
||||
}
|
||||
const projectData: Partial<ProjectData> = {
|
||||
title: '',
|
||||
description: '',
|
||||
@@ -277,9 +280,9 @@ export const parseExcel = (excelData: unknown[][], options?: Partial<ExcelImport
|
||||
} else if (j === subtitleIndex) {
|
||||
event.subtitle = makeString(column, '');
|
||||
} else if (j === isPublicIndex) {
|
||||
event.isPublic = Boolean(column);
|
||||
event.isPublic = column == 'x' ? true : coerceBoolean(column);
|
||||
} else if (j === skipIndex) {
|
||||
event.skip = Boolean(column);
|
||||
event.skip = column == 'x' ? true : coerceBoolean(column);
|
||||
} else if (j === notesIndex) {
|
||||
event.note = makeString(column, '');
|
||||
} else if (j === endActionIndex) {
|
||||
@@ -370,8 +373,6 @@ export const parseJson = async (jsonData): Promise<DatabaseModel | null> => {
|
||||
returnData.osc = parseOsc(jsonData) ?? dbModel.osc;
|
||||
// Import HTTP settings if any
|
||||
returnData.http = parseHttp(jsonData) ?? dbModel.http;
|
||||
// Import GoogleSheet settings if any
|
||||
returnData.googleSheet = parseGoogleSheet(jsonData, true);
|
||||
|
||||
return returnData as DatabaseModel;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { generateId } from 'ontime-utils';
|
||||
import {
|
||||
Alias,
|
||||
GoogleSheet,
|
||||
OntimeRundown,
|
||||
HttpSettings,
|
||||
OSCSettings,
|
||||
@@ -332,25 +331,4 @@ export const parseUserFields = (data): UserFields => {
|
||||
}
|
||||
}
|
||||
return { ...newUserFields };
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse Google Sheet portion of an entry
|
||||
* @param {object} data - data object
|
||||
* @param {boolean} enforce - whether to create a definition if one is missing
|
||||
* @returns {object} - event object data
|
||||
*/
|
||||
export const parseGoogleSheet = (data, enforce) => {
|
||||
const newSheet: GoogleSheet = {
|
||||
id: '',
|
||||
worksheet: '',
|
||||
};
|
||||
if ('googleSheet' in data) {
|
||||
console.log('Found Google Sheet definition, importing...');
|
||||
newSheet.id ??= data.googleSheet?.id;
|
||||
newSheet.worksheet ??= data.googleSheet?.worksheet;
|
||||
return newSheet;
|
||||
} else if (enforce) {
|
||||
return newSheet;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -36,7 +36,7 @@ export function getA1Notation(row: number, column: number): string {
|
||||
* @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 cellRequenstFromEvent(
|
||||
export function cellRequestFromEvent(
|
||||
event: OntimeRundownEntry,
|
||||
index: number,
|
||||
worksheetId: number,
|
||||
@@ -50,7 +50,7 @@ export function cellRequenstFromEvent(
|
||||
const titleCol = tmp[0][1].col;
|
||||
|
||||
for (const [index, e] of tmp.entries()) {
|
||||
if (index != 0) {
|
||||
if (index !== 0) {
|
||||
const prevCol = tmp[index - 1][1].col;
|
||||
const thisCol = e[1].col;
|
||||
const diff = thisCol - prevCol;
|
||||
+253
-264
@@ -1,296 +1,120 @@
|
||||
import { sheets, sheets_v4 } from '@googleapis/sheets';
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { readFileSync } from 'fs';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import http from 'http';
|
||||
import { DatabaseModel, GoogleSheetState, LogOrigin } from 'ontime-types';
|
||||
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 { cellRequenstFromEvent, cellRequenstFromProjectData, getA1Notation } from './googleSheetUtils.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 {
|
||||
class Sheet {
|
||||
private static client: null | OAuth2Client = null;
|
||||
private readonly scope = 'https://www.googleapis.com/auth/spreadsheets';
|
||||
private readonly sheetsFolder;
|
||||
private readonly client_secret;
|
||||
private readonly sheetsFolder: string;
|
||||
private readonly clientSecretFile: string;
|
||||
private static clientSecret = null;
|
||||
private static authUrl: null | string = null;
|
||||
private worksheetId = 0;
|
||||
private sheetId = '';
|
||||
private range = '';
|
||||
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('Could not resolve public folder for platform');
|
||||
throw new Error('Sheet: Could not resolve sheet folser');
|
||||
}
|
||||
this.sheetsFolder = join(appDataPath, 'sheets');
|
||||
this.client_secret = join(this.sheetsFolder, 'client_secret.json');
|
||||
this.clientSecretFile = join(this.sheetsFolder, 'client_secret.json');
|
||||
ensureDirectory(this.sheetsFolder);
|
||||
}
|
||||
|
||||
public async getSheetState(): Promise<GoogleSheetState> {
|
||||
const ret: GoogleSheetState = {
|
||||
auth: false,
|
||||
id: false,
|
||||
worksheet: false,
|
||||
};
|
||||
this.sheetId = '';
|
||||
this.worksheetId = 0;
|
||||
if (!sheet.client) {
|
||||
return ret;
|
||||
}
|
||||
try {
|
||||
ret.auth = await this.refreshToken();
|
||||
if (ret.auth) {
|
||||
const settings = DataProvider.getGoogleSheet();
|
||||
const x = await this.exist(settings.id, settings.worksheet);
|
||||
if (x === true) {
|
||||
ret.id = true;
|
||||
this.sheetId = settings.id;
|
||||
} else if (x !== false) {
|
||||
ret.id = true;
|
||||
ret.worksheet = true;
|
||||
this.sheetId = settings.id;
|
||||
this.worksheetId = x.worksheetId;
|
||||
this.range = x.range;
|
||||
}
|
||||
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 (err) {
|
||||
logger.error(LogOrigin.Server, `Google Sheet: Faild to refresh token ${err}`);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<false | {worksheetId: number, range: string}>} - false if not found | true if sheetId existes | id of worksheet and rage of worksheet
|
||||
* @throws
|
||||
*/
|
||||
private async exist(
|
||||
sheetId: string,
|
||||
worksheet: string,
|
||||
): Promise<false | true | { 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 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* push sheet
|
||||
* @throws
|
||||
*/
|
||||
public async push() {
|
||||
const { auth, id, worksheet } = await this.getSheetState();
|
||||
if (!auth && !id && !worksheet) {
|
||||
throw new Error(`Sheet not authorized or incorrect ID or worksheet`);
|
||||
}
|
||||
|
||||
const rq = await sheets({ version: 'v4', auth: sheet.client }).spreadsheets.values.get({
|
||||
spreadsheetId: this.sheetId,
|
||||
valueRenderOption: 'FORMATTED_VALUE',
|
||||
majorDimension: 'ROWS',
|
||||
range: this.range,
|
||||
});
|
||||
if (rq.status === 200) {
|
||||
const { rundownMetadata, projectMetadata } = parseExcel(rq.data.values);
|
||||
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: this.worksheetId,
|
||||
},
|
||||
},
|
||||
});
|
||||
//and delete the rest
|
||||
updateRundown.push({
|
||||
deleteDimension: { range: { dimension: 'ROWS', startIndex: titleRow + 2, sheetId: this.worksheetId } },
|
||||
});
|
||||
// insert the lenght of the rundown
|
||||
updateRundown.push({
|
||||
insertDimension: {
|
||||
inheritFromBefore: false,
|
||||
range: {
|
||||
dimension: 'ROWS',
|
||||
startIndex: titleRow + 1,
|
||||
endIndex: titleRow + rundown.length,
|
||||
sheetId: this.worksheetId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
//update the corresponding row with event data
|
||||
rundown.forEach((entry, index) =>
|
||||
updateRundown.push(cellRequenstFromEvent(entry, index, this.worksheetId, rundownMetadata)),
|
||||
);
|
||||
|
||||
//update project data
|
||||
updateRundown.push(cellRequenstFromProjectData(projectData, this.worksheetId, projectMetadata));
|
||||
|
||||
const writeResponds = await sheets({ version: 'v4', auth: sheet.client }).spreadsheets.batchUpdate({
|
||||
spreadsheetId: this.sheetId,
|
||||
requestBody: {
|
||||
includeSpreadsheetInResponse: false,
|
||||
responseRanges: [this.range],
|
||||
requests: updateRundown,
|
||||
},
|
||||
});
|
||||
|
||||
if (writeResponds.status == 200) {
|
||||
logger.info(LogOrigin.Server, `Sheet write: ${writeResponds.statusText}`);
|
||||
} else {
|
||||
throw new Error(`Sheet write faild: ${writeResponds.statusText}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Sheet read faild: ${rq.statusText}`);
|
||||
} catch (_) {
|
||||
/* empty - it is ok thet there is no clientSecret */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* pull sheet
|
||||
* @returns {Promise<Partial<ResponseOK>>}
|
||||
* @throws
|
||||
*/
|
||||
public async pull(): Promise<Partial<ResponseOK>> {
|
||||
const { auth, id, worksheet } = await this.getSheetState();
|
||||
if (!auth && !id && !worksheet) {
|
||||
throw new Error(`Sheet not authorized or incorrect ID or worksheet`);
|
||||
}
|
||||
|
||||
const res: Partial<ResponseOK> = {};
|
||||
|
||||
const rq = await sheets({ version: 'v4', auth: sheet.client }).spreadsheets.values.get({
|
||||
spreadsheetId: this.sheetId,
|
||||
valueRenderOption: 'FORMATTED_VALUE',
|
||||
majorDimension: 'ROWS',
|
||||
range: this.range,
|
||||
});
|
||||
if (rq.status === 200) {
|
||||
res.data = {};
|
||||
const dataFromSheet = parseExcel(rq.data.values);
|
||||
res.data.rundown = parseRundown(dataFromSheet);
|
||||
if (res.data.rundown.length < 1) {
|
||||
throw new Error(`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 faild: ${rq.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* saves secrets object to appdata path as client_secret.json
|
||||
* @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;
|
||||
if (
|
||||
!('client_id' in secrets['installed']) ||
|
||||
!('project_id' in secrets['installed']) ||
|
||||
!('auth_uri' in secrets['installed']) ||
|
||||
!('token_uri' in secrets['installed']) ||
|
||||
!('auth_provider_x509_cert_url' in secrets['installed']) ||
|
||||
!('client_secret' in secrets['installed']) ||
|
||||
!('redirect_uris' in secrets['installed'])
|
||||
) {
|
||||
throw new Error('Sheet slient secret is missing some keys');
|
||||
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.client_secret, JSON.stringify(secrets), 'utf-8').catch((err) =>
|
||||
logger.error(LogOrigin.Server, `${err}`),
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* refresh the client token
|
||||
* @returns {Promise<boolean>}
|
||||
* @description SETP 1 - test that the saved object is pressent
|
||||
*/
|
||||
async refreshToken(): Promise<boolean> {
|
||||
if (!sheet.client?.credentials?.refresh_token) return false;
|
||||
try {
|
||||
const response = await sheet.client.refreshAccessToken();
|
||||
if (response?.credentials) {
|
||||
return true;
|
||||
}
|
||||
} catch (_) {
|
||||
logger.info(LogOrigin.Server, 'Sheets token expired');
|
||||
}
|
||||
return false;
|
||||
testClientSecret() {
|
||||
return Sheet.clientSecret !== null;
|
||||
}
|
||||
|
||||
private authServerTimeout;
|
||||
/**
|
||||
* create local Auth Server
|
||||
* @returns {Promise<string | false>} - returns url to serve on success
|
||||
* @description SETP 2 - create server to interact with th OAuth2 request
|
||||
* @returns {Promise<string | null>} - returns url path serve on success
|
||||
* @throws
|
||||
*/
|
||||
public async openAuthServer(): Promise<string | false> {
|
||||
async openAuthServer(): Promise<string | null> {
|
||||
//TODO: this only works on local networks
|
||||
if (sheet.authUrl) {
|
||||
|
||||
// if the server is allready running retun it
|
||||
if (Sheet.authUrl) {
|
||||
clearTimeout(this.authServerTimeout);
|
||||
this.authServerTimeout = setTimeout(
|
||||
() => {
|
||||
sheet.authUrl = null;
|
||||
Sheet.authUrl = null;
|
||||
server.unref;
|
||||
},
|
||||
2 * 60 * 1000,
|
||||
);
|
||||
return sheet.authUrl;
|
||||
}
|
||||
const creadFile = await readFile(this.client_secret, 'utf-8').catch((err) =>
|
||||
logger.error(LogOrigin.Server, `${err}`),
|
||||
);
|
||||
if (!creadFile) {
|
||||
return false;
|
||||
}
|
||||
const keyFile = JSON.parse(creadFile);
|
||||
const keys = keyFile.installed || keyFile.web;
|
||||
if (!keys.redirect_uris || keys.redirect_uris.length === 0) {
|
||||
logger.error(LogOrigin.Server, `${invalidRedirectUri}`);
|
||||
return false;
|
||||
return Sheet.authUrl;
|
||||
}
|
||||
|
||||
// create an oAuth client to authorize the API call
|
||||
// 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(invalidRedirectUri);
|
||||
throw new Error('Sheet: Invalid redirect URI');
|
||||
}
|
||||
|
||||
// create an oAuth client to authorize the API call
|
||||
@@ -299,6 +123,7 @@ class sheet {
|
||||
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');
|
||||
@@ -323,7 +148,7 @@ class sheet {
|
||||
redirect_uri: redirectUri.toString(),
|
||||
});
|
||||
client.credentials = tokens;
|
||||
sheet.client = client;
|
||||
Sheet.client = client;
|
||||
res.end('Authentication successful! Please close this tab and return to OnTime.');
|
||||
logger.info(LogOrigin.Server, `Sheet: Authentication successful`);
|
||||
} catch (e) {
|
||||
@@ -351,41 +176,205 @@ class sheet {
|
||||
access_type: 'offline',
|
||||
scope: this.scope,
|
||||
});
|
||||
sheet.authUrl = authorizeUrl;
|
||||
Sheet.authUrl = authorizeUrl;
|
||||
this.authServerTimeout = setTimeout(
|
||||
() => {
|
||||
sheet.authUrl = null;
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copyright 2020 Google LLC
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//TODO: add modification notifications as requrirde by the license
|
||||
|
||||
const invalidRedirectUri = `The provided keyfile does not define a valid
|
||||
redirect URI. There must be at least one redirect URI defined, and this sample
|
||||
assumes it redirects to 'http://localhost:3000/oauth2callback'. Please edit
|
||||
your keyfile, and add a 'redirect_uris' section. For example:
|
||||
|
||||
"redirect_uris": [
|
||||
"http://localhost:3000/oauth2callback"
|
||||
]
|
||||
`;
|
||||
|
||||
export const Sheet = new sheet();
|
||||
export const sheet = new Sheet();
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
},
|
||||
"settings": {
|
||||
"app": "ontime",
|
||||
"version": "2.21.3",
|
||||
"version": "2.24.8",
|
||||
"serverPort": 4001,
|
||||
"editorKey": null,
|
||||
"operatorKey": null,
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+4
-2
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "2.21.3",
|
||||
"version": "2.28.16",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"lighdev",
|
||||
"lightdev",
|
||||
"ontime",
|
||||
"timer",
|
||||
"rundown"
|
||||
@@ -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",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { OSCSettings } from './core/OscSettings.type.js';
|
||||
import { Settings } from './core/Settings.type.js';
|
||||
import { UserFields } from './core/UserFields.type.js';
|
||||
import { ViewSettings } from './core/Views.type.js';
|
||||
import { GoogleSheet, HttpSettings } from '../index.js';
|
||||
import { HttpSettings } from '../index.js';
|
||||
|
||||
export type DatabaseModel = {
|
||||
rundown: OntimeRundown;
|
||||
@@ -14,7 +14,6 @@ export type DatabaseModel = {
|
||||
viewSettings: ViewSettings;
|
||||
aliases: Alias[];
|
||||
userFields: UserFields;
|
||||
googleSheet: GoogleSheet;
|
||||
osc: OSCSettings;
|
||||
http: HttpSettings;
|
||||
};
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export type GoogleSheet = {
|
||||
worksheet: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type GoogleSheetState = {
|
||||
auth: boolean;
|
||||
id: boolean;
|
||||
worksheet: boolean;
|
||||
};
|
||||
@@ -37,9 +37,6 @@ export type { OSCSettings, OscSubscription, OscSubscriptionOptions } from './def
|
||||
// ---> HTTP
|
||||
export type { HttpSettings, HttpSubscription, HttpSubscriptionOptions } from './definitions/core/HttpSettings.type.js';
|
||||
|
||||
// ---> Google Sheet
|
||||
export type { GoogleSheet, GoogleSheetState } from './definitions/core/GoogleSheet.type.js';
|
||||
|
||||
// SERVER RESPONSES
|
||||
export type { NetworkInterface, GetInfo } from './api/ontime-controller/BackendResponse.type.js';
|
||||
export type { GetRundownCached } from './api/rundown-controller/BackendResponse.type.js';
|
||||
|
||||
@@ -93,6 +93,17 @@ describe('validateTimes()', () => {
|
||||
expect(timeEnd).toBe(10);
|
||||
expect(duration).toBe(10);
|
||||
});
|
||||
|
||||
it('ensures values dont overflow dayMs', () => {
|
||||
const start = 86100000;
|
||||
const endOverDay = 87420000;
|
||||
const durationNormal = 1320000;
|
||||
|
||||
const { timeStart, timeEnd, duration } = validateTimes(start, endOverDay, durationNormal);
|
||||
expect(timeStart).toBe(start);
|
||||
expect(timeEnd).toBe(1020000);
|
||||
expect(duration).toBe(durationNormal);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateDuration()', () => {
|
||||
|
||||
@@ -52,9 +52,9 @@ function convertToInteger(value: unknown): number {
|
||||
* @param _duration
|
||||
*/
|
||||
export function validateTimes(_start?: unknown, _end?: unknown, _duration?: unknown) {
|
||||
const timeStart = convertToInteger(_start);
|
||||
const timeEnd = convertToInteger(_end);
|
||||
const duration = convertToInteger(_duration);
|
||||
const timeStart = convertToInteger(_start) % dayInMs;
|
||||
const timeEnd = convertToInteger(_end) % dayInMs;
|
||||
const duration = convertToInteger(_duration) % dayInMs;
|
||||
|
||||
if (_start != null && _end != null) {
|
||||
// Case 1. if we have start and end, duration must be derived
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"build:local": {},
|
||||
"build:electron": {},
|
||||
"build:docker": {},
|
||||
"build:localdocker": {},
|
||||
"e2e": {
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user