Compare commits

...

24 Commits

Author SHA1 Message Date
arc-alex c6f61b16cb remove console.log 2024-01-06 18:26:37 +01:00
arc-alex 08a32a7265 add ExcelImport 2024-01-06 18:21:22 +01:00
arc-alex 341d554bc5 add link to docs 2024-01-06 16:23:43 +01:00
arc-alex 4e49ca3ebe useEffect on is open 2024-01-06 15:41:55 +01:00
arc-alex 19536c3ea8 Reareange everything to match the 5 steps 2024-01-06 15:38:16 +01:00
arc-alex dc8b55aa53 use maybeAxiosError 2024-01-03 01:54:56 +01:00
arc-alex cb6dee552f display errors in UI 2024-01-03 01:52:30 +01:00
arc-alex ba89673d5f clarify updateSheetState 2024-01-03 00:55:31 +01:00
arc-alex a719f5827e use the send-to-link function 2024-01-02 15:57:14 +01:00
arc-alex 7fcf8153af cleanup and rename 2024-01-02 15:49:10 +01:00
arc-alex e0b2ea8f5c sort import and fix variant 2023-12-22 14:25:22 +01:00
arc-alex be92fa43fa allow x as boolean true from sheet 2023-12-22 14:17:36 +01:00
arc-alex fda1dbdd5f add some comments 2023-12-22 13:57:21 +01:00
arc-alex 8ae1c6de42 use api directly 2023-12-19 13:33:57 +01:00
Carlos Valente fa56f48461 refactor: get data from query 2023-12-19 12:34:00 +01:00
arc-alex 364d698bbc dont save sheet settings 2023-12-18 14:23:03 +01:00
arc-alex 8fa7392698 dont save 2023-12-16 23:56:15 +01:00
arc-alex 2464ac1bd9 change some naming 2023-12-16 23:37:44 +01:00
Carlos Valente a6683fd2cd style: small UI suggestion 2023-12-15 22:25:26 +01:00
arc-alex d2c6d46585 use exist function befor pull or push 2023-12-15 13:40:53 +01:00
Carlos Valente 95d7b61529 refactor: move fetching to react query 2023-12-15 10:49:52 +01:00
arc-alex 884947374d improve get state check 2023-12-13 18:56:49 +01:00
arc-alex e52ff58d74 coerce boolean types from sheet 2023-12-13 17:37:33 +01:00
arc-alex 0024b02638 small refinment 2023-12-13 17:31:33 +01:00
21 changed files with 865 additions and 620 deletions
@@ -9,6 +9,7 @@ export const HTTP_SETTINGS = ['httpSettings'];
export const APP_SETTINGS = ['appSettings']; export const APP_SETTINGS = ['appSettings'];
export const VIEW_SETTINGS = ['viewSettings']; export const VIEW_SETTINGS = ['viewSettings'];
export const RUNTIME = ['runtimeStore']; export const RUNTIME = ['runtimeStore'];
export const SHEET_STATE = ['sheetState'];
const location = window.location; const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws'; const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
+48 -36
View File
@@ -3,8 +3,6 @@ import {
Alias, Alias,
DatabaseModel, DatabaseModel,
GetInfo, GetInfo,
GoogleSheet,
GoogleSheetState,
HttpSettings, HttpSettings,
OntimeRundown, OntimeRundown,
OSCSettings, OSCSettings,
@@ -249,14 +247,13 @@ export async function postNew(initialData: Partial<ProjectData>) {
} }
/** /**
* @description sheet Client File * @description STEP 1
* @return {Promise}
*/ */
export const uploadSheetClientFile = async (file: File) => { export const uploadSheetClientFile = async (file: File) => {
const formData = new FormData(); const formData = new FormData();
formData.append('userFile', file); formData.append('userFile', file);
const res = await axios const res = await axios
.post(`${ontimeURL}/sheet-clientsecrect`, formData, { .post(`${ontimeURL}/sheet/clientsecrect`, formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
@@ -265,44 +262,59 @@ export const uploadSheetClientFile = async (file: File) => {
return res; return res;
}; };
/**
* @description STEP 1 test
*/
export const getClientSecrect = async () => {
const response = await axios.get(`${ontimeURL}/sheet/clientsecrect`);
return response.data;
};
/**
* @description STEP 2
*/
export const getSheetsAuthUrl = async () => { export const getSheetsAuthUrl = async () => {
const res = await axios.get(`${ontimeURL}/sheet-authurl`); const response = await axios.get(`${ontimeURL}/sheet/authentication/url`);
return res.data; return response.data;
}; };
export const postPreviewSheet = async () => { /**
const response = await axios.post(`${ontimeURL}/sheet-preview`); * @description STEP 2 test
return response.data.data; */
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; return response.data.data;
}; };
/** /**
* @description HTTP request to retrieve google sheets settings * @description STEP 5
* @return {Promise}
*/ */
export async function getSheetSettings(): Promise<GoogleSheet> { export const postPushSheet = async (id: string, options: ExcelImportMap) => {
const res = await axios.get(`${ontimeURL}/sheet-settings`); const response = await axios.post(`${ontimeURL}/sheet-push`, { id, options });
return res.data; return response.data.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;
}
@@ -1,6 +1,11 @@
import { ChangeEvent, useEffect, useRef, useState } from 'react'; import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Button, Button,
HStack,
Input, Input,
Modal, Modal,
ModalBody, ModalBody,
@@ -9,29 +14,33 @@ import {
ModalFooter, ModalFooter,
ModalHeader, ModalHeader,
ModalOverlay, ModalOverlay,
Select,
} from '@chakra-ui/react'; } 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 { 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 { PROJECT_DATA, RUNDOWN, USERFIELDS } from '../../../common/api/apiConstants';
import { maybeAxiosError } from '../../../common/api/apiUtils'; import { maybeAxiosError } from '../../../common/api/apiUtils';
import { import {
getAuthentication,
getClientSecrect,
getSheetsAuthUrl, getSheetsAuthUrl,
getSheetSettings,
getSheetstate,
patchData, patchData,
postId,
postPreviewSheet, postPreviewSheet,
postPushSheet, postPushSheet,
postSheetSettings, postWorksheet,
uploadSheetClientFile, uploadSheetClientFile,
} from '../../../common/api/ontimeApi'; } from '../../../common/api/ontimeApi';
import { projectDataPlaceholder } from '../../../common/models/ProjectData'; import { projectDataPlaceholder } from '../../../common/models/ProjectData';
import { userFieldsPlaceholder } from '../../../common/models/UserFields'; import { userFieldsPlaceholder } from '../../../common/models/UserFields';
import { openLink } from '../../../common/utils/linkUtils';
import ModalLink from '../ModalLink';
import PreviewExcel from '../upload-modal/preview/PreviewExcel'; import PreviewExcel from '../upload-modal/preview/PreviewExcel';
import ExcelFileOptions from '../upload-modal/upload-options/ExcelFileOptions';
import Step from './Step';
interface SheetsModalProps { interface SheetsModalProps {
onClose: () => void; onClose: () => void;
@@ -41,18 +50,37 @@ interface SheetsModalProps {
export default function SheetsModal(props: SheetsModalProps) { export default function SheetsModal(props: SheetsModalProps) {
const { isOpen, onClose } = props; const { isOpen, onClose } = props;
const fileInputRef = useRef<HTMLInputElement>(null); const queryClient = useQueryClient();
const [rundown, setRundown] = useState<OntimeRundown | null>(null); const [rundown, setRundown] = useState<OntimeRundown | null>(null);
const [userFields, setUserFields] = useState<UserFields | null>(null); const [userFields, setUserFields] = useState<UserFields | null>(null);
const [project, setProject] = useState<ProjectData | 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(new Array<string>());
const queryClient = useQueryClient(); const [direction, setDirection] = useState('none');
const excelFileOptions = useRef<ExcelImportMap>(defaultExcelImportMap);
const sheetid = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const worksheet = useRef<HTMLInputElement>(null);
const [state, setState] = useState({
clientSecret: { complet: false, message: '' },
authenticate: { complet: false, message: '' },
id: { complet: false, message: '' },
worksheet: { complet: false, message: '' },
pullPush: { complet: false, message: '' },
});
useEffect(() => {
if (isOpen) {
setDirection('none');
testClientSecrect();
if (state.clientSecret.complet) testAuthentication();
if (state.authenticate.complet) testSheetId();
}
}, [isOpen]);
const handleClose = () => { const handleClose = () => {
setRundown(null); setRundown(null);
@@ -60,73 +88,157 @@ export default function SheetsModal(props: SheetsModalProps) {
setUserFields(null); setUserFields(null);
onClose(); onClose();
}; };
//SETP-1 Upload Client ID
const handleClick = () => { const handleClick = () => {
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
const handleFile = async (event: ChangeEvent<HTMLInputElement>) => { const handleFile = (event: ChangeEvent<HTMLInputElement>) => {
const selectedFile = event?.target?.files?.[0]; if (!event.target.files?.length) {
if (selectedFile) { setState({
await uploadSheetClientFile(selectedFile).catch((err) => { clientSecret: { complet: false, message: 'Missing file' },
console.error(err); //TODO: how to show this to the user authenticate: { complet: false, message: '' },
id: { complet: false, message: '' },
worksheet: { complet: false, message: '' },
pullPush: { complet: false, message: '' },
}); });
_onChange(); return;
}
const selectedFile = event.target.files[0];
uploadSheetClientFile(selectedFile)
.then(() => {
setState({ ...state, clientSecret: { complet: true, message: '' } });
})
.catch((err) => {
const message = maybeAxiosError(err);
setState({
clientSecret: { complet: false, message },
authenticate: { complet: false, message: '' },
id: { complet: false, message: '' },
worksheet: { complet: false, message: '' },
pullPush: { complet: false, message: '' },
});
});
};
const testClientSecrect = () => {
getClientSecrect()
.then(() => {
setState({ ...state, clientSecret: { complet: true, message: '' } });
})
.catch((err) => {
const message = maybeAxiosError(err);
setState({
clientSecret: { complet: false, message },
authenticate: { complet: false, message: '' },
id: { complet: false, message: '' },
worksheet: { complet: false, message: '' },
pullPush: { complet: 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: { complet: false, message },
id: { complet: false, message: '' },
worksheet: { complet: false, message: '' },
pullPush: { complet: false, message: '' },
});
});
};
const testAuthentication = () => {
getAuthentication()
.then(() => {
setState({ ...state, authenticate: { complet: true, message: '' } });
})
.catch((error) => {
const message = maybeAxiosError(error);
setState({
...state,
authenticate: { complet: false, message },
id: { complet: false, message: '' },
worksheet: { complet: false, message: '' },
pullPush: { complet: false, message: '' },
});
});
};
//SETP-3 set sheet ID
const testSheetId = () => {
postId(id)
.then((data) => {
setState({ ...state, id: { complet: true, message: '' } });
setWorksheetOptions(data.worksheetOptions);
})
.catch((err) => {
const message = maybeAxiosError(err);
setState({
...state,
id: { complet: false, message },
worksheet: { complet: false, message: '' },
pullPush: { complet: false, message: '' },
});
setWorksheetOptions([]);
});
};
//SETP-4 Select Worksheet
const testWorksheet = (value: string) => {
excelFileOptions.current.worksheet = value;
setWorksheet(value);
postWorksheet(id, worksheet)
.then(() => {
setState({ ...state, worksheet: { complet: true, message: '' } });
})
.catch((err) => {
const message = maybeAxiosError(err);
setState({ ...state, worksheet: { complet: false, message }, pullPush: { complet: 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 = () => { const handlePullData = () => {
postPreviewSheet().then((data) => { postPreviewSheet(id, excelFileOptions.current)
setProject(data.project); .then((data) => {
setRundown(data.rundown); setProject(data.project);
setUserFields(data.userFields); setRundown(data.rundown);
}); setUserFields(data.userFields);
})
.catch((error) => {
const message = maybeAxiosError(error);
setDirection('none');
setState({ ...state, pullPush: { complet: false, message } });
});
}; };
const handlePushData = () => { const handlePushData = () => {
postPushSheet(); postPushSheet(id, excelFileOptions.current).catch((error) => {
const message = maybeAxiosError(error);
setDirection('none');
setState({ ...state, pullPush: { complet: false, message } });
});
}; };
//GET preview
const handleFinalise = async () => { const handleFinalise = async () => {
// this step is currently only used for excel files, after preview
if (rundown && userFields && project) { if (rundown && userFields && project) {
let doClose = false; let doClose = false;
try { try {
@@ -140,8 +252,7 @@ export default function SheetsModal(props: SheetsModalProps) {
doClose = true; doClose = true;
} catch (error) { } catch (error) {
const message = maybeAxiosError(error); const message = maybeAxiosError(error);
console.log(message); console.error(message);
// setErrors(`Failed applying changes ${message}`);
} finally { } finally {
if (doClose) { if (doClose) {
handleClose(); handleClose();
@@ -163,93 +274,184 @@ export default function SheetsModal(props: SheetsModalProps) {
> >
<ModalOverlay /> <ModalOverlay />
<ModalContent> <ModalContent>
<ModalHeader>Sheets!</ModalHeader> <ModalHeader>Rundown from sheets</ModalHeader>
<ModalCloseButton /> <ModalCloseButton />
<ModalBody> <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-experimental'>
For more information, see the docs
</ModalLink>
</AlertDescription>
</div>
</Alert>
{!rundown ? (
direction === 'up' ? (
<div>{direction}</div>
) : direction === 'down' ? (
<ExcelFileOptions optionsRef={excelFileOptions} updateOptions={updateExcelFileOptions} />
) : (
<>
<Step
title='1 - Upload OAuth 2.0 Client ID'
completed={state.clientSecret.complet}
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.complet ? 'Reupload Client ID' : 'Upload Client ID'}
</Button>
<Button size='sm' variant='ontime-ghosted-on-light' onClick={testClientSecrect}>
Retry Client ID
</Button>
</div>
</Step>
<Step
title='2 - Authenticate with Google'
completed={state.authenticate.complet}
disabled={!state.clientSecret.complet}
error={state.authenticate.message}
>
<div style={{ display: 'flex', gap: '1em' }}>
<Button
size='sm'
variant='ontime-subtle-on-light'
onClick={handleAuthenticate}
isDisabled={!state.clientSecret.complet}
>
Authenticate
</Button>
<Button
size='sm'
variant='ontime-ghosted-on-light'
onClick={testAuthentication}
isDisabled={!state.clientSecret.complet}
>
Retry Connection
</Button>
</div>
</Step>
<Step
title='3 - Add Document ID'
completed={state.id.complet}
disabled={!state.authenticate.complet}
error={state.id.message}
>
<HStack>
<Input
type='text'
size='sm'
variant='ontime-filled-on-light'
disabled={!state.authenticate.complet}
value={id}
onChange={(event) => setSheetId(event.target.value)}
/>
<Button 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.complet}
disabled={worksheetOptions.length == 0}
>
<Select
size='sm'
disabled={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.complet}>
<div style={{ display: 'flex', gap: '1em' }}>
<Button
disabled={!state.worksheet.complet}
variant='ontime-subtle-on-light'
padding='0 2em'
onClick={() => setDirection('up')}
>
Upload
</Button>
<Button
disabled={!state.worksheet.complet}
variant='ontime-subtle-on-light'
padding='0 2em'
onClick={() => setDirection('down')}
>
Download
</Button>
</div>
</Step>
</>
)
) : (
<PreviewExcel <PreviewExcel
rundown={rundown ?? []} rundown={rundown ?? []}
project={project ?? projectDataPlaceholder} project={project ?? projectDataPlaceholder}
userFields={userFields ?? userFieldsPlaceholder} 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> </ModalBody>
<ModalFooter> <ModalFooter>
{!rundown && ( {rundown ? (
<div> <div style={{ display: 'flex', gap: '1em' }}>
<Button <Button
variant='ontime-subtle-on-light' onClick={() => {
padding='0 2em' setRundown(null);
onClick={handlePullData} setDirection('none');
rightIcon={<IoArrowDownCircleOutline />} }}
variant='ontime-ghost-on-light'
> >
Pull data Go Back
</Button> </Button>
<Button <Button variant='ontime-filled' padding='0 2em' onClick={handleFinalise}>
variant='ontime-subtle-on-light' Import
padding='0 2em'
onClick={handlePushData}
rightIcon={<IoArrowUpCircleOutline />}
>
Push data
</Button> </Button>
</div> </div>
)} ) : direction === 'up' ? (
<Button variant='ontime-ghost-on-light'>Reset</Button> <div style={{ display: 'flex', gap: '1em' }}>
{!rundown && ( <Button onClick={() => setDirection('none')} variant='ontime-ghost-on-light'>
<Button variant='ontime-filled' padding='0 2em' onClick={handelSave}> Go Back
Save </Button>
</Button> <Button variant='ontime-filled' padding='0 2em' onClick={handlePushData}>
)} Upload
{rundown && ( </Button>
<Button variant='ontime-filled' padding='0 2em' onClick={handleFinalise}> </div>
Import ) : direction === 'down' ? (
</Button> <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}>
Review
</Button>
</div>
) : (
<></>
)} )}
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
@@ -0,0 +1,41 @@
@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;
}
.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>
);
}
-5
View File
@@ -138,11 +138,6 @@ function createWindow() {
}); });
win.setMenu(null); win.setMenu(null);
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
} }
app.disableHardwareAcceleration(); app.disableHardwareAcceleration();
@@ -11,7 +11,6 @@ import {
UserFields, UserFields,
Alias, Alias,
Settings, Settings,
GoogleSheet,
} from 'ontime-types'; } from 'ontime-types';
import { data, db } from '../../modules/loadDb.js'; import { data, db } from '../../modules/loadDb.js';
@@ -59,15 +58,6 @@ export class DataProvider {
await this.persist(); await this.persist();
} }
static getGoogleSheet() {
return data.googleSheet;
}
static async setGoogleSheet(newData: GoogleSheet) {
data.googleSheet = { ...newData };
await this.persist();
}
static getOsc() { static getOsc() {
return data.osc; return data.osc;
} }
@@ -6,13 +6,12 @@ import { DatabaseModel } from 'ontime-types';
* @param {object} newData * @param {object} newData
*/ */
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>) { 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 { return {
...existing, ...existing,
rundown: rundown ?? existing.rundown, rundown: rundown ?? existing.rundown,
project: { ...existing.project, ...project }, project: { ...existing.project, ...project },
settings: { ...existing.settings, ...settings }, settings: { ...existing.settings, ...settings },
googleSheet: { ...existing.googleSheet, ...googleSheet },
viewSettings: { ...existing.viewSettings, ...viewSettings }, viewSettings: { ...existing.viewSettings, ...viewSettings },
aliases: aliases ?? existing.aliases, aliases: aliases ?? existing.aliases,
userFields: { 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'; import { safeMerge } from '../DataProvider.utils.js';
describe('safeMerge', () => { describe('safeMerge', () => {
@@ -21,10 +21,6 @@ describe('safeMerge', () => {
timeFormat: '24', timeFormat: '24',
language: 'en', language: 'en',
}, },
googleSheet: {
worksheet: '1',
id: '2',
},
viewSettings: { viewSettings: {
overrideStyles: false, overrideStyles: false,
endMessage: 'existing endMessage', endMessage: 'existing endMessage',
@@ -101,21 +97,6 @@ describe('safeMerge', () => {
language: 'pt', 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', () => { it('merges the osc key', () => {
const newData = { const newData = {
osc: { osc: {
+86 -65
View File
@@ -20,7 +20,7 @@ import { runtimeCacheStore } from '../stores/cachingStore.js';
import { delayedRundownCacheKey } from '../services/rundown-service/delayedRundown.utils.js'; import { delayedRundownCacheKey } from '../services/rundown-service/delayedRundown.utils.js';
import { integrationService } from '../services/integration-service/IntegrationService.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' // Create controller for GET request to '/ontime/poll'
// Returns data for current state // Returns data for current state
@@ -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 * @returns parsed result
*/ */
export async function previewSheet(req, res) { export async function uploadSheetClientFile(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) {
if (!req.file.path) { if (!req.file.path) {
res.status(400).send({ message: 'File not found' }); res.status(400).send({ message: 'File not found' });
return; return;
} }
try { try {
const client = JSON.parse(fs.readFileSync(req.file.path as string, 'utf-8')); 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'); res.status(200).send('OK');
} catch (error) { } catch (error) {
res.status(500).send({ message: error.toString() }); 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) { export const getClientSecrect = async (req, res) => {
const successful = await Sheet.openAuthServer(); try {
if (successful === false) { const clientSecrectExists = await sheet.testClientSecrect();
res.status(500).send('bad'); if (clientSecrectExists) {
} else { res.status(200).send();
res.status(200).send(successful); } 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 * @description SETP-2 GET sheet authentication status
* @method GET
*/ */
export const getGoogleSheetSettings = async (req, res) => { export const getAuthentication = 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;
}
try { try {
const newData = { await sheet.testAuthentication();
id: req.body.id, res.status(200).send();
worksheet: req.body.worksheet,
};
await DataProvider.setGoogleSheet(newData);
res.status(200).send(newData);
} catch (error) { } catch (error) {
res.status(400).send({ message: error.toString() }); res.status(500).send({ message: error.toString() });
} }
}; };
/** /**
* @description Get google sheet state * @description SETP-3 POST sheet id
* @method GET * @returns list of worksheets
*/ */
export const getGoogleSheetState = async (req, res) => { export const postId = async (req, res) => {
res.status(200).send(await Sheet.getSheetState()); 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 validateSheetid = [
export const validateSheetPreview = [ body('id').exists().isString(),
body('sheetid').isString().optional({ nullable: false }),
body('worksheet').isString().optional({ nullable: false }),
body('options').isObject().optional({ nullable: true }),
(req, res, next) => { (req, res, next) => {
const errors = validationResult(req); const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
@@ -165,9 +162,19 @@ export const validateSheetPreview = [
}, },
]; ];
export const validateGoogleSheetSettings = [ export const validateWorksheet = [
body('id').isString().optional({ nullable: false }), body('id').exists().isString(),
body('worksheet').isString().optional({ nullable: false }), 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) => { (req, res, next) => {
const errors = validationResult(req); const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
-4
View File
@@ -20,10 +20,6 @@ export const dbModel: DatabaseModel = {
timeFormat: '24', timeFormat: '24',
language: 'en', language: 'en',
}, },
googleSheet: {
worksheet: '',
id: '',
},
viewSettings: { viewSettings: {
overrideStyles: false, overrideStyles: false,
normalColor: '#ffffffcc', normalColor: '#ffffffcc',
+23 -23
View File
@@ -21,26 +21,27 @@ import {
postViewSettings, postViewSettings,
previewExcel, previewExcel,
postHTTP, postHTTP,
sheetAuthUrl, getAuthenticationUrl,
uploadGoogleSheetClientFile, uploadSheetClientFile,
previewSheet, pullSheet,
pushSheet, pushSheet,
getGoogleSheetSettings, postId,
postGoogleSheetSettings, getAuthentication,
getGoogleSheetState, getClientSecrect,
} from '../controllers/ontimeController.js'; } from '../controllers/ontimeController.js';
import { import {
validateAliases, validateAliases,
validateGoogleSheetSettings,
validateOSC, validateOSC,
validatePatchProjectFile, validatePatchProjectFile,
validateSettings, validateSettings,
validateSheetPreview,
validateUserFields, validateUserFields,
viewValidator, viewValidator,
validateHTTP, validateHTTP,
validateOscSubscription, validateOscSubscription,
validateSheetid,
validateWorksheet,
validateSheetOptions,
} from '../controllers/ontimeController.validate.js'; } from '../controllers/ontimeController.validate.js';
import { projectSanitiser } from '../controllers/projectController.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 // create route between controller and '/ontime/new' endpoint
router.post('/new', projectSanitiser, postNew); router.post('/new', projectSanitiser, postNew);
// create route between controller and '/ontime/sheet-client' endpoint //SETP-1
router.post('/sheet-clientsecrect', uploadFile, uploadGoogleSheetClientFile); router.post('/sheet/clientsecrect', uploadFile, uploadSheetClientFile);
router.get('/sheet/clientsecrect', uploadFile, getClientSecrect);
// create route between controller and '/ontime/sheet-authstatus' endpoint //SETP-2
router.get('/sheet-authurl', sheetAuthUrl); router.get('/sheet/authentication/url', getAuthenticationUrl);
router.get('/sheet/authentication', getAuthentication);
// create route between controller and '/ontime/preview-sheet' endpoint //STEP-3
router.post('/sheet-preview', validateSheetPreview, previewSheet); router.post('/sheet/id', validateSheetid, postId);
// create route between controller and '/ontime/preview-sheet' endpoint //STEP-4
router.post('/sheet-push', pushSheet); router.post('/sheet/worksheet', validateWorksheet, postId);
// create route between controller and '/ontime/sheet-settings' endpoint //STEP-5 download and generate preview
router.get('/sheet-settings', getGoogleSheetSettings); router.post('/sheet/pull', validateSheetOptions, pullSheet);
// create route between controller and '/ontime/sheet-settings' endpoint //STEP-5 upload
router.post('/sheet-settings', validateGoogleSheetSettings, postGoogleSheetSettings); router.post('/sheet-push', validateSheetOptions, pushSheet);
// create route between controller and '/ontime/sheet-state' endpoint
router.get('/sheet-state', getGoogleSheetState);
@@ -1,5 +1,5 @@
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import { getA1Notation, cellRequenstFromEvent, cellRequenstFromProjectData } from '../googleSheetUtils.js'; import { getA1Notation, cellRequenstFromEvent, cellRequenstFromProjectData } from '../sheetUtils.js';
import { EndAction, OntimeRundownEntry, ProjectData, SupportedEvent, TimerType } from 'ontime-types'; import { EndAction, OntimeRundownEntry, ProjectData, SupportedEvent, TimerType } from 'ontime-types';
describe('getA1Notation()', () => { describe('getA1Notation()', () => {
+3 -5
View File
@@ -34,9 +34,9 @@ import {
parseSettings, parseSettings,
parseUserFields, parseUserFields,
parseViewSettings, parseViewSettings,
parseGoogleSheet,
} from './parserFunctions.js'; } from './parserFunctions.js';
import { parseExcelDate } from './time.js'; import { parseExcelDate } from './time.js';
import { coerceBoolean } from './coerceType.js';
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
export const JSON_MIME = 'application/json'; export const JSON_MIME = 'application/json';
@@ -277,9 +277,9 @@ export const parseExcel = (excelData: unknown[][], options?: Partial<ExcelImport
} else if (j === subtitleIndex) { } else if (j === subtitleIndex) {
event.subtitle = makeString(column, ''); event.subtitle = makeString(column, '');
} else if (j === isPublicIndex) { } else if (j === isPublicIndex) {
event.isPublic = Boolean(column); event.isPublic = column == 'x' ? true : coerceBoolean(column);
} else if (j === skipIndex) { } else if (j === skipIndex) {
event.skip = Boolean(column); event.skip = column == 'x' ? true : coerceBoolean(column);
} else if (j === notesIndex) { } else if (j === notesIndex) {
event.note = makeString(column, ''); event.note = makeString(column, '');
} else if (j === endActionIndex) { } else if (j === endActionIndex) {
@@ -370,8 +370,6 @@ export const parseJson = async (jsonData): Promise<DatabaseModel | null> => {
returnData.osc = parseOsc(jsonData) ?? dbModel.osc; returnData.osc = parseOsc(jsonData) ?? dbModel.osc;
// Import HTTP settings if any // Import HTTP settings if any
returnData.http = parseHttp(jsonData) ?? dbModel.http; returnData.http = parseHttp(jsonData) ?? dbModel.http;
// Import GoogleSheet settings if any
returnData.googleSheet = parseGoogleSheet(jsonData, true);
return returnData as DatabaseModel; return returnData as DatabaseModel;
}; };
-22
View File
@@ -1,7 +1,6 @@
import { generateId } from 'ontime-utils'; import { generateId } from 'ontime-utils';
import { import {
Alias, Alias,
GoogleSheet,
OntimeRundown, OntimeRundown,
HttpSettings, HttpSettings,
OSCSettings, OSCSettings,
@@ -333,24 +332,3 @@ export const parseUserFields = (data): UserFields => {
} }
return { ...newUserFields }; 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;
}
};
+255 -264
View File
@@ -1,296 +1,120 @@
import { sheets, sheets_v4 } from '@googleapis/sheets'; 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 { OAuth2Client } from 'google-auth-library';
import http from 'http'; import http from 'http';
import { DatabaseModel, GoogleSheetState, LogOrigin } from 'ontime-types'; import { DatabaseModel, LogOrigin } from 'ontime-types';
import { join } from 'path'; import { join } from 'path';
import { URL } from 'url'; import { URL } from 'url';
import { logger } from '../classes/Logger.js'; import { logger } from '../classes/Logger.js';
import { DataProvider } from '../classes/data-provider/DataProvider.js'; import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { getAppDataPath } from '../setup.js'; import { getAppDataPath } from '../setup.js';
import { ensureDirectory } from './fileManagement.js'; import { ensureDirectory } from './fileManagement.js';
import { cellRequenstFromEvent, cellRequenstFromProjectData, getA1Notation } from './googleSheetUtils.js'; import { cellRequenstFromEvent, cellRequenstFromProjectData, getA1Notation } from './sheetUtils.js';
import { parseExcel } from './parser.js'; import { parseExcel } from './parser.js';
import { parseProject, parseRundown, parseUserFields } from './parserFunctions.js'; import { parseProject, parseRundown, parseUserFields } from './parserFunctions.js';
import { ExcelImportMap } from 'ontime-utils';
type ResponseOK = { type ResponseOK = {
data: Partial<DatabaseModel>; data: Partial<DatabaseModel>;
}; };
class sheet { class Sheet {
private static client: null | OAuth2Client = null; private static client: null | OAuth2Client = null;
private readonly scope = 'https://www.googleapis.com/auth/spreadsheets'; private readonly scope = 'https://www.googleapis.com/auth/spreadsheets';
private readonly sheetsFolder; private readonly sheetsFolder: string;
private readonly client_secret; private readonly clientSecretFile: string;
private static clientSecret = null;
private static authUrl: null | string = null; private static authUrl: null | string = null;
private worksheetId = 0; private authServerTimeout;
private sheetId = '';
private range = ''; private readonly requiredClientKeys = [
'client_id',
'project_id',
'auth_uri',
'token_uri',
'auth_provider_x509_cert_url',
'client_secret',
'redirect_uris',
];
constructor() { constructor() {
const appDataPath = getAppDataPath(); const appDataPath = getAppDataPath();
if (appDataPath === '') { 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.sheetsFolder = join(appDataPath, 'sheets');
this.client_secret = join(this.sheetsFolder, 'client_secret.json'); this.clientSecretFile = join(this.sheetsFolder, 'client_secret.json');
ensureDirectory(this.sheetsFolder); 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 { try {
ret.auth = await this.refreshToken(); const secrets = JSON.parse(readFileSync(this.clientSecretFile, 'utf-8'));
if (ret.auth) { const isKeyMissing = this.requiredClientKeys.some((key) => !(key in secrets['installed']));
const settings = DataProvider.getGoogleSheet(); if (!isKeyMissing) {
const x = await this.exist(settings.id, settings.worksheet); Sheet.clientSecret = secrets;
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;
}
} }
} catch (err) { } catch (_) {
logger.error(LogOrigin.Server, `Google Sheet: Faild to refresh token ${err}`); /* empty - it is ok thet there is no clientSecret */
}
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}`);
} }
} }
/** /**
* pull sheet * @description SETP 1 - saves secrets object to appdata path as client_secret.json
* @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
* @param {object} secrets * @param {object} secrets
* @throws * @throws
*/ */
public async saveClientSecrets(secrets: object) { public async saveClientSecrets(secrets: object) {
sheet.client = null; Sheet.client = null;
sheet.authUrl = null; Sheet.authUrl = null;
if ( Sheet.clientSecret = null;
!('client_id' in secrets['installed']) ||
!('project_id' in secrets['installed']) || const isKeyMissing = this.requiredClientKeys.some((key) => !(key in secrets['installed']));
!('auth_uri' in secrets['installed']) || if (isKeyMissing) {
!('token_uri' in secrets['installed']) || throw new Error('Client file is missing some keys');
!('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');
} }
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 * @description SETP 1 - test that the saved object is pressent
* @returns {Promise<boolean>}
*/ */
async refreshToken(): Promise<boolean> { testClientSecrect() {
if (!sheet.client?.credentials?.refresh_token) return false; return Sheet.clientSecret !== null;
try {
const response = await sheet.client.refreshAccessToken();
if (response?.credentials) {
return true;
}
} catch (_) {
logger.info(LogOrigin.Server, 'Sheets token expired');
}
return false;
} }
private authServerTimeout;
/** /**
* create local Auth Server * @description SETP 2 - create server to interact with th OAuth2 request
* @returns {Promise<string | false>} - returns url to serve on success * @returns {Promise<string | null>} - returns url path serve on success
* @throws * @throws
*/ */
public async openAuthServer(): Promise<string | false> { async openAuthServer(): Promise<string | null> {
//TODO: this only works on local networks //TODO: this only works on local networks
if (sheet.authUrl) {
// if the server is allready running retun it
if (Sheet.authUrl) {
clearTimeout(this.authServerTimeout); clearTimeout(this.authServerTimeout);
this.authServerTimeout = setTimeout( this.authServerTimeout = setTimeout(
() => { () => {
sheet.authUrl = null; Sheet.authUrl = null;
server.unref; server.unref;
}, },
2 * 60 * 1000, 2 * 60 * 1000,
); );
return sheet.authUrl; 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;
} }
// 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]); const redirectUri = new URL(keys.redirect_uris[0]);
if (redirectUri.hostname !== 'localhost') { if (redirectUri.hostname !== 'localhost') {
throw new Error(invalidRedirectUri); throw new Error('Sheet: Invalid redirect URI');
} }
// create an oAuth client to authorize the API call // create an oAuth client to authorize the API call
@@ -299,6 +123,7 @@ class sheet {
clientSecret: keys.client_secret, clientSecret: keys.client_secret,
}); });
// start the server that will recive the codes
const server = http.createServer(async (req, res) => { const server = http.createServer(async (req, res) => {
try { try {
const serverUrl = new URL(req.url, 'http://localhost:3000'); const serverUrl = new URL(req.url, 'http://localhost:3000');
@@ -323,7 +148,7 @@ class sheet {
redirect_uri: redirectUri.toString(), redirect_uri: redirectUri.toString(),
}); });
client.credentials = tokens; client.credentials = tokens;
sheet.client = client; Sheet.client = client;
res.end('Authentication successful! Please close this tab and return to OnTime.'); res.end('Authentication successful! Please close this tab and return to OnTime.');
logger.info(LogOrigin.Server, `Sheet: Authentication successful`); logger.info(LogOrigin.Server, `Sheet: Authentication successful`);
} catch (e) { } catch (e) {
@@ -351,41 +176,207 @@ class sheet {
access_type: 'offline', access_type: 'offline',
scope: this.scope, scope: this.scope,
}); });
sheet.authUrl = authorizeUrl; Sheet.authUrl = authorizeUrl;
this.authServerTimeout = setTimeout( this.authServerTimeout = setTimeout(
() => { () => {
sheet.authUrl = null; Sheet.authUrl = null;
server.unref(); server.unref();
}, },
2 * 60 * 1000, 2 * 60 * 1000,
); );
return authorizeUrl; 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 accese 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 rq = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.values.get({
spreadsheetId: id,
valueRenderOption: 'FORMATTED_VALUE',
majorDimension: 'ROWS',
range: range,
});
if (rq.status === 200) {
const { rundownMetadata, projectMetadata } = parseExcel(rq.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(cellRequenstFromEvent(entry, index, worksheetId, rundownMetadata)),
);
//update project data
updateRundown.push(cellRequenstFromProjectData(projectData, worksheetId, projectMetadata));
const writeResponds = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.batchUpdate({
spreadsheetId: id,
requestBody: {
includeSpreadsheetInResponse: false,
responseRanges: [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}`);
}
}
/**
* @description SETP 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 rq = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.values.get({
spreadsheetId: id,
valueRenderOption: 'FORMATTED_VALUE',
majorDimension: 'ROWS',
range,
});
if (rq.status === 200) {
res.data = {};
const dataFromSheet = parseExcel(rq.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`);
}
console.log(dataFromSheet.project);
console.log(dataFromSheet.userFields);
res.data.project = parseProject(dataFromSheet);
res.data.userFields = parseUserFields(dataFromSheet);
return res;
} else {
throw new Error(`Sheet: read faild: ${rq.statusText}`);
}
}
} }
// Copyright 2020 Google LLC export const sheet = new Sheet();
// 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();
@@ -5,7 +5,7 @@ import { OSCSettings } from './core/OscSettings.type.js';
import { Settings } from './core/Settings.type.js'; import { Settings } from './core/Settings.type.js';
import { UserFields } from './core/UserFields.type.js'; import { UserFields } from './core/UserFields.type.js';
import { ViewSettings } from './core/Views.type.js'; import { ViewSettings } from './core/Views.type.js';
import { GoogleSheet, HttpSettings } from '../index.js'; import { HttpSettings } from '../index.js';
export type DatabaseModel = { export type DatabaseModel = {
rundown: OntimeRundown; rundown: OntimeRundown;
@@ -14,7 +14,6 @@ export type DatabaseModel = {
viewSettings: ViewSettings; viewSettings: ViewSettings;
aliases: Alias[]; aliases: Alias[];
userFields: UserFields; userFields: UserFields;
googleSheet: GoogleSheet;
osc: OSCSettings; osc: OSCSettings;
http: HttpSettings; http: HttpSettings;
}; };
@@ -1,10 +0,0 @@
export type GoogleSheet = {
worksheet: string;
id: string;
};
export type GoogleSheetState = {
auth: boolean;
id: boolean;
worksheet: boolean;
};
-3
View File
@@ -37,9 +37,6 @@ export type { OSCSettings, OscSubscription, OscSubscriptionOptions } from './def
// ---> HTTP // ---> HTTP
export type { HttpSettings, HttpSubscription, HttpSubscriptionOptions } from './definitions/core/HttpSettings.type.js'; 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 // SERVER RESPONSES
export type { NetworkInterface, GetInfo } from './api/ontime-controller/BackendResponse.type.js'; export type { NetworkInterface, GetInfo } from './api/ontime-controller/BackendResponse.type.js';
export type { GetRundownCached } from './api/rundown-controller/BackendResponse.type.js'; export type { GetRundownCached } from './api/rundown-controller/BackendResponse.type.js';