mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-02 12:59:09 +00:00
Sheet use limited input device auth flow (#782)
* refactor: limited-input-device auth * refactor: resolve sheet directory from setup * refactor: extract sheet logic in backend * refactor: simplify sheet integration --------- Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com> Co-authored-by: Carlos Valente <carlosvalente@pm.me>
This commit is contained in:
committed by
GitHub
parent
fc5338903b
commit
474f1e2177
@@ -10,6 +10,7 @@ export const RUNDOWN = ['rundown'];
|
|||||||
export const RUNTIME = ['runtimeStore'];
|
export const RUNTIME = ['runtimeStore'];
|
||||||
export const SHEET_STATE = ['sheetState'];
|
export const SHEET_STATE = ['sheetState'];
|
||||||
export const USERFIELDS = ['userFields'];
|
export const USERFIELDS = ['userFields'];
|
||||||
|
export const CUSTOM_FIELDS = ['customFields'];
|
||||||
export const VIEW_SETTINGS = ['viewSettings'];
|
export const VIEW_SETTINGS = ['viewSettings'];
|
||||||
|
|
||||||
const location = window.location;
|
const location = window.location;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import {
|
import {
|
||||||
Alias,
|
Alias,
|
||||||
|
AuthenticationStatus,
|
||||||
|
CustomField,
|
||||||
|
CustomFieldLabel,
|
||||||
|
CustomFields,
|
||||||
DatabaseModel,
|
DatabaseModel,
|
||||||
GetInfo,
|
GetInfo,
|
||||||
HttpSettings,
|
HttpSettings,
|
||||||
@@ -18,7 +22,7 @@ import { ExcelImportMap } from 'ontime-utils';
|
|||||||
import { apiRepoLatest } from '../../externals';
|
import { apiRepoLatest } from '../../externals';
|
||||||
import fileDownload from '../utils/fileDownload';
|
import fileDownload from '../utils/fileDownload';
|
||||||
|
|
||||||
import { ontimeURL } from './apiConstants';
|
import { ontimeURL, projectDataURL } from './apiConstants';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description HTTP request to retrieve application settings
|
* @description HTTP request to retrieve application settings
|
||||||
@@ -257,79 +261,65 @@ export async function loadProject(filename: string): Promise<MessageResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description STEP 1
|
* @description HTTP request to initiate the authentication service with google
|
||||||
*/
|
*/
|
||||||
export const uploadSheetClientFile = async (file: File) => {
|
export const requestConnection = async (
|
||||||
|
file: File,
|
||||||
|
sheetId: string,
|
||||||
|
): Promise<{
|
||||||
|
verification_url: string;
|
||||||
|
user_code: string;
|
||||||
|
}> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('userFile', file);
|
formData.append('client_secret', file);
|
||||||
const res = await axios
|
|
||||||
.post(`${ontimeURL}/sheet/clientsecret`, formData, {
|
const response = await axios.post(`${ontimeURL}/sheet/${sheetId}/connect`, formData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
.then((response) => response.data.id);
|
|
||||||
return res;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description STEP 1 test
|
|
||||||
*/
|
|
||||||
// TODO: do we still need this?
|
|
||||||
export const getClientSecret = async () => {
|
|
||||||
const response = await axios.get(`${ontimeURL}/sheet/clientsecret`);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description STEP 2
|
* @description HTTP request to verify whether we are authenticated with Google Sheet service
|
||||||
*/
|
*/
|
||||||
export const getSheetsAuthUrl = async () => {
|
export const verifyAuthenticationStatus = async (): Promise<{ authenticated: AuthenticationStatus }> => {
|
||||||
const response = await axios.get(`${ontimeURL}/sheet/authentication/url`);
|
const response = await axios.get(`${ontimeURL}/sheet/connect`);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description STEP 2 test
|
* @description HTTP request to revoke authentication to google sheet
|
||||||
*/
|
*/
|
||||||
export const getAuthentication = async () => {
|
export const revokeAuthentication = async (): Promise<{ authenticated: AuthenticationStatus }> => {
|
||||||
const response = await axios.get(`${ontimeURL}/sheet/authentication`);
|
const response = await axios.post(`${ontimeURL}/sheet/revoke`);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description STEP 3
|
* @description HTTP request to upload preview the contents of a google sheet as rundown
|
||||||
* @returns worksheetOptions
|
|
||||||
*/
|
*/
|
||||||
export const postId = async (sheetId: string) => {
|
export const previewRundown = async (
|
||||||
const response = await axios.post(`${ontimeURL}/sheet/sheetId`, { sheetId });
|
sheetId: string,
|
||||||
|
options: ExcelImportMap,
|
||||||
|
): Promise<{
|
||||||
|
rundown: OntimeRundown;
|
||||||
|
userFields: UserFields;
|
||||||
|
}> => {
|
||||||
|
const response = await axios.post(`${ontimeURL}/sheet/${sheetId}/read`, { options });
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description STEP 4
|
* @description HTTP request to upload the rundown to a google sheet
|
||||||
*/
|
*/
|
||||||
export const postWorksheet = async (sheetId: string, worksheet: string) => {
|
export const uploadRundown = async (sheetId: string, options: ExcelImportMap): Promise<void> => {
|
||||||
const response = await axios.post(`${ontimeURL}/sheet/worksheet`, { sheetId, worksheet });
|
const response = await axios.post(`${ontimeURL}/sheet/${sheetId}/write`, { options });
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* @description STEP 5
|
|
||||||
*/
|
|
||||||
export const postPreviewSheet = async (sheetId: string, options: ExcelImportMap) => {
|
|
||||||
const response = await axios.post(`${ontimeURL}/sheet-pull`, { sheetId, options });
|
|
||||||
return response.data.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description STEP 5
|
|
||||||
*/
|
|
||||||
export const postPushSheet = async (sheetId: string, options: ExcelImportMap) => {
|
|
||||||
const response = await axios.post(`${ontimeURL}/sheet-push`, { sheetId, options });
|
|
||||||
return response.data.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description HTTP request to rename a project file
|
* @description HTTP request to rename a project file
|
||||||
*/
|
*/
|
||||||
@@ -374,8 +364,33 @@ export async function createProject(
|
|||||||
}
|
}
|
||||||
>,
|
>,
|
||||||
): Promise<MessageResponse> {
|
): Promise<MessageResponse> {
|
||||||
|
// TODO: is this URL correct?
|
||||||
const url = `${ontimeURL}/project`;
|
const url = `${ontimeURL}/project`;
|
||||||
const decodedUrl = decodeURIComponent(url);
|
const decodedUrl = decodeURIComponent(url);
|
||||||
const res = await axios.post(decodedUrl, project);
|
const res = await axios.post(decodedUrl, project);
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCustomFields(): Promise<CustomFields> {
|
||||||
|
const res = await axios.get(`${projectDataURL}/custom-field`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postCustomField(newField: CustomField): Promise<CustomFields> {
|
||||||
|
const res = await axios.post(`${projectDataURL}/custom-field`, {
|
||||||
|
...newField,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editCustomField(label: CustomFieldLabel, newField: CustomField): Promise<CustomFields> {
|
||||||
|
const res = await axios.put(`${projectDataURL}/custom-field/${label}`, {
|
||||||
|
...newField,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCustomField(label: CustomFieldLabel): Promise<CustomFields> {
|
||||||
|
const res = await axios.delete(`${projectDataURL}/custom-field/${label}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,20 +10,28 @@ interface CopyTagProps {
|
|||||||
label: string;
|
label: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
size?: Size;
|
size?: Size;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
|
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
|
||||||
const { label, className, size = 'xs', children } = props;
|
const { label, className, size = 'xs', disabled, children } = props;
|
||||||
|
|
||||||
const handleClick = () => copyToClipboard(children as string);
|
const handleClick = () => copyToClipboard(children as string);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={label} openDelay={tooltipDelayFast}>
|
<Tooltip label={label} openDelay={tooltipDelayFast}>
|
||||||
<ButtonGroup size={size} isAttached className={className}>
|
<ButtonGroup size={size} isAttached className={className}>
|
||||||
<Button variant='ontime-subtle' tabIndex={-1}>
|
<Button variant='ontime-subtle' tabIndex={-1} isDisabled={disabled}>
|
||||||
{children}
|
{children}
|
||||||
</Button>
|
</Button>
|
||||||
<IconButton aria-label={label} icon={<IoCopy />} variant='ontime-filled' tabIndex={-1} onClick={handleClick} />
|
<IconButton
|
||||||
|
aria-label={label}
|
||||||
|
icon={<IoCopy />}
|
||||||
|
variant='ontime-filled'
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={handleClick}
|
||||||
|
isDisabled={disabled}
|
||||||
|
/>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,21 +6,24 @@ import style from './SwatchSelect.module.scss';
|
|||||||
|
|
||||||
interface SwatchProps {
|
interface SwatchProps {
|
||||||
color: string;
|
color: string;
|
||||||
onClick: (color: string) => void;
|
onClick?: (color: string) => void;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Swatch(props: SwatchProps) {
|
export default function Swatch(props: SwatchProps) {
|
||||||
const { color, isSelected, onClick } = props;
|
const { color, isSelected, onClick } = props;
|
||||||
|
|
||||||
const classes = cx([style.swatch, isSelected ? style.selected : null]);
|
const handleClick = () => {
|
||||||
|
onClick?.(color);
|
||||||
|
};
|
||||||
|
const classes = cx([style.swatch, isSelected ? style.selected : null, onClick ? style.selectable : null]);
|
||||||
|
|
||||||
if (!color) {
|
if (!color) {
|
||||||
return (
|
return (
|
||||||
<div className={`${classes} ${style.center}`} onClick={() => onClick('')}>
|
<div className={`${classes} ${style.center}`} onClick={handleClick}>
|
||||||
<IoBan />
|
<IoBan />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <div className={classes} style={{ backgroundColor: `${color}` }} onClick={() => onClick(color)} />;
|
return <div className={classes} style={{ backgroundColor: `${color}` }} onClick={handleClick} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.swatch {
|
.swatch {
|
||||||
cursor: pointer;
|
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
@@ -15,6 +14,10 @@
|
|||||||
&.selected {
|
&.selected {
|
||||||
border: 2px solid $blue-500;
|
border: 2px solid $blue-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.selectable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.center {
|
.center {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { CustomFields } from 'ontime-types';
|
||||||
|
|
||||||
|
import { queryRefetchInterval } from '../../ontimeConfig';
|
||||||
|
import { CUSTOM_FIELDS } from '../api/apiConstants';
|
||||||
|
import { getCustomFields } from '../api/ontimeApi';
|
||||||
|
|
||||||
|
const placeholder: CustomFields = {};
|
||||||
|
|
||||||
|
export default function useCustomFields() {
|
||||||
|
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||||
|
queryKey: CUSTOM_FIELDS,
|
||||||
|
queryFn: getCustomFields,
|
||||||
|
placeholderData: placeholder,
|
||||||
|
retry: 5,
|
||||||
|
retryDelay: (attempt) => attempt * 2500,
|
||||||
|
refetchInterval: queryRefetchInterval,
|
||||||
|
networkMode: 'always',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data: data ?? placeholder, status, isFetching, isError, refetch };
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isIPAddress, isOnlyNumbers, startsWithHttp, startsWithSlash } from '../regex';
|
import { isAlphanumeric, isIPAddress, isNotEmpty, isOnlyNumbers, startsWithHttp, startsWithSlash } from '../regex';
|
||||||
|
|
||||||
describe('simple tests for regex', () => {
|
describe('simple tests for regex', () => {
|
||||||
test('isOnlyNumbers', () => {
|
test('isOnlyNumbers', () => {
|
||||||
@@ -48,4 +48,28 @@ describe('simple tests for regex', () => {
|
|||||||
expect(startsWithSlash.test(t)).toBe(false);
|
expect(startsWithSlash.test(t)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('isAlphanumeric', () => {
|
||||||
|
const right = ['dsafdsafa9f9sdafdsSADFHASDF', '1231', '1', 'a', 'asdas1asdas', '11as', '1'];
|
||||||
|
const wrong = ['with space', 'with @', '#'];
|
||||||
|
|
||||||
|
right.forEach((t) => {
|
||||||
|
expect(isAlphanumeric.test(t)).toBe(true);
|
||||||
|
});
|
||||||
|
wrong.forEach((t) => {
|
||||||
|
expect(isAlphanumeric.test(t)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isNotEmpty', () => {
|
||||||
|
const right = ['notempty'];
|
||||||
|
const wrong = ['', ' '];
|
||||||
|
|
||||||
|
right.forEach((t) => {
|
||||||
|
expect(isNotEmpty.test(t)).toBe(true);
|
||||||
|
});
|
||||||
|
wrong.forEach((t) => {
|
||||||
|
expect(isNotEmpty.test(t)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,19 @@ import { OntimeEvent, SupportedEvent } from 'ontime-types';
|
|||||||
*/
|
*/
|
||||||
type ClonedEvent = Omit<
|
type ClonedEvent = Omit<
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
'id' | 'cue' | 'user0' | 'user1' | 'user2' | 'user3' | 'user4' | 'user5' | 'user6' | 'user7' | 'user8' | 'user9'
|
| 'id'
|
||||||
|
| 'cue'
|
||||||
|
| 'user0'
|
||||||
|
| 'user1'
|
||||||
|
| 'user2'
|
||||||
|
| 'user3'
|
||||||
|
| 'user4'
|
||||||
|
| 'user5'
|
||||||
|
| 'user6'
|
||||||
|
| 'user7'
|
||||||
|
| 'user8'
|
||||||
|
| 'user9'
|
||||||
|
| 'custom'
|
||||||
>;
|
>;
|
||||||
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
|
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ export const isOnlyNumbers = /^\d+$/;
|
|||||||
export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
|
export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
|
||||||
export const startsWithHttp = /^http:\/\//;
|
export const startsWithHttp = /^http:\/\//;
|
||||||
export const startsWithSlash = /^\//;
|
export const startsWithSlash = /^\//;
|
||||||
|
export const isAlphanumeric = /^[a-z0-9]+$/i;
|
||||||
|
export const isNotEmpty = /\S/;
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ export default function OscIntegrations() {
|
|||||||
|
|
||||||
<Panel.Divider />
|
<Panel.Divider />
|
||||||
|
|
||||||
|
|
||||||
<Panel.Section as='form' id='osc-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
|
<Panel.Section as='form' id='osc-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
|
||||||
<Panel.Title>OSC Settings</Panel.Title>
|
<Panel.Title>OSC Settings</Panel.Title>
|
||||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||||
|
|||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { IconButton } from '@chakra-ui/react';
|
||||||
|
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
|
||||||
|
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||||
|
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||||
|
|
||||||
|
import Swatch from '../../../../common/components/input/colour-input/Swatch';
|
||||||
|
|
||||||
|
import CustomFieldForm from './CustomFieldForm';
|
||||||
|
|
||||||
|
import style from './ProjectSettingsPanel.module.scss';
|
||||||
|
|
||||||
|
interface CustomFieldEntryProps {
|
||||||
|
colour: string;
|
||||||
|
label: string;
|
||||||
|
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
|
||||||
|
onDelete: (label: CustomFieldLabel) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||||
|
const { colour, label, onEdit, onDelete } = props;
|
||||||
|
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
|
const handleEdit = async (patch: CustomField) => {
|
||||||
|
const oldLabel = label;
|
||||||
|
await onEdit(oldLabel, patch);
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={99}>
|
||||||
|
<CustomFieldForm
|
||||||
|
onCancel={() => setIsEditing(false)}
|
||||||
|
onSubmit={handleEdit}
|
||||||
|
initialColour={colour}
|
||||||
|
initialLabel={label}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<Swatch color={colour} />
|
||||||
|
</td>
|
||||||
|
<td className={style.fullWidth}>{label}</td>
|
||||||
|
<td className={style.actions}>
|
||||||
|
<IconButton
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-ghosted'
|
||||||
|
color='#e2e2e2' // $gray-200
|
||||||
|
icon={<IoPencil />}
|
||||||
|
aria-label='Edit entry'
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-ghosted'
|
||||||
|
color='#FA5656' // $red-500
|
||||||
|
icon={<IoTrash />}
|
||||||
|
aria-label='Delete entry'
|
||||||
|
onClick={() => onDelete(label)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { Button, Input } from '@chakra-ui/react';
|
||||||
|
import { CustomField } from 'ontime-types';
|
||||||
|
import { isAlphanumeric } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { maybeAxiosError } from '../../../../common/api/apiUtils';
|
||||||
|
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
|
||||||
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
|
import style from './ProjectSettingsPanel.module.scss';
|
||||||
|
|
||||||
|
interface CustomFieldsFormProps {
|
||||||
|
onSubmit: (field: CustomField) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
initialColour?: string;
|
||||||
|
initialLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||||
|
const { onSubmit, onCancel, initialColour, initialLabel } = props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
register,
|
||||||
|
setFocus,
|
||||||
|
setError,
|
||||||
|
setValue,
|
||||||
|
getValues,
|
||||||
|
formState: { errors, isSubmitting, isValid, isDirty },
|
||||||
|
} = useForm({
|
||||||
|
defaultValues: { label: initialLabel || '', colour: initialColour || '' },
|
||||||
|
resetOptions: {
|
||||||
|
keepDirtyValues: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const setupSubmit = async (values: { label: string; colour: string }) => {
|
||||||
|
const { label, colour } = values;
|
||||||
|
const newField: CustomField = {
|
||||||
|
type: 'string', // type is not user definable yet
|
||||||
|
colour,
|
||||||
|
label,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await onSubmit(newField);
|
||||||
|
} catch (error) {
|
||||||
|
setError('root', { type: 'custom', message: maybeAxiosError(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// give initial focus to the label
|
||||||
|
useEffect(() => {
|
||||||
|
setFocus('label');
|
||||||
|
}, [setFocus]);
|
||||||
|
|
||||||
|
const handleSelectColour = (colour: string) => {
|
||||||
|
setValue('colour', colour, { shouldDirty: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const colour = getValues('colour');
|
||||||
|
const canSubmit = isDirty && isValid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
|
||||||
|
<div className={style.column}>
|
||||||
|
<Panel.Description>Label</Panel.Description>
|
||||||
|
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
|
||||||
|
<Input
|
||||||
|
{...register('label', {
|
||||||
|
required: { value: true, message: 'Required field' },
|
||||||
|
validate: (value) => {
|
||||||
|
if (value.trim().length === 0) return 'Required field';
|
||||||
|
if (!isAlphanumeric(value)) return 'Only alphanumeric characters are allowed';
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-filled'
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Panel.Description>Colour</Panel.Description>
|
||||||
|
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
||||||
|
</div>
|
||||||
|
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||||
|
<div className={style.buttonRow}>
|
||||||
|
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size='sm' type='submit' variant='ontime-filled' isDisabled={!canSubmit} isLoading={isSubmitting}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
+17
-3
@@ -1,8 +1,22 @@
|
|||||||
.fullWidth {
|
.fullWidth {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldForm {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: $gray-1350;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-42
@@ -1,74 +1,94 @@
|
|||||||
import { Alert, AlertDescription, AlertIcon, IconButton } from '@chakra-ui/react';
|
import { useState } from 'react';
|
||||||
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
|
import { Alert, AlertDescription, AlertIcon, Button } from '@chakra-ui/react';
|
||||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||||
|
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||||
|
|
||||||
|
import { deleteCustomField, editCustomField, postCustomField } from '../../../../common/api/ontimeApi';
|
||||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||||
|
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
import style from './ProjectSettingsPanel.module.scss';
|
import CustomFieldEntry from './CustomFieldEntry';
|
||||||
|
import CustomFieldForm from './CustomFieldForm';
|
||||||
const demoCustomFields = {
|
|
||||||
Apple: { value: 'Fruit' },
|
|
||||||
Dog: { value: 'Animal' },
|
|
||||||
Sun: { value: 'Star' },
|
|
||||||
Car: { value: 'Vehicle' },
|
|
||||||
Tree: { value: 'Plant' },
|
|
||||||
Bird: { value: 'Creature' },
|
|
||||||
Book: { value: 'Reading' },
|
|
||||||
Chair: { value: 'Furniture' },
|
|
||||||
Music: { value: 'Melody' },
|
|
||||||
Ocean: { value: 'Sea' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const userFieldsDocsUrl = 'https://ontime.gitbook.io/v2/features/user-fields';
|
const userFieldsDocsUrl = 'https://ontime.gitbook.io/v2/features/user-fields';
|
||||||
|
|
||||||
export default function ProjectSettingsPanel() {
|
export default function ProjectSettingsPanel() {
|
||||||
|
const { data, refetch } = useCustomFields();
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
|
||||||
|
const handleInitiateCreate = () => {
|
||||||
|
setIsAdding(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsAdding(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async (customField: CustomField) => {
|
||||||
|
await postCustomField(customField);
|
||||||
|
refetch();
|
||||||
|
setIsAdding(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditField = async (label: CustomFieldLabel, customField: CustomField) => {
|
||||||
|
await editCustomField(label, customField);
|
||||||
|
refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (label: string) => {
|
||||||
|
try {
|
||||||
|
await deleteCustomField(label);
|
||||||
|
refetch();
|
||||||
|
} catch (_error) {
|
||||||
|
/** we do not handle errors here */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Panel.Header>Project Settings</Panel.Header>
|
<Panel.Header>Project Settings</Panel.Header>
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Card>
|
<Panel.Card>
|
||||||
<Panel.SubHeader>Custom fields</Panel.SubHeader>
|
<Panel.SubHeader>
|
||||||
<div>
|
Custom fields
|
||||||
|
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleInitiateCreate}>
|
||||||
|
New
|
||||||
|
</Button>
|
||||||
|
</Panel.SubHeader>
|
||||||
|
<Panel.Section>
|
||||||
<Alert status='info' variant='ontime-on-dark-info'>
|
<Alert status='info' variant='ontime-on-dark-info'>
|
||||||
<AlertIcon />
|
<AlertIcon />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Custom fields allow for additional information to be added to an event (eg. light, sound, camera).{' '}
|
Custom fields allow for additional information to be added to an event (eg. light, sound, camera).{' '}
|
||||||
<br />
|
<br />
|
||||||
This data is not used by Ontime. <br />
|
<br />
|
||||||
|
This data is not used by Ontime.
|
||||||
<ExternalLink href={userFieldsDocsUrl}>See the docs</ExternalLink>
|
<ExternalLink href={userFieldsDocsUrl}>See the docs</ExternalLink>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</Panel.Section>
|
||||||
|
{isAdding && <CustomFieldForm onSubmit={handleCreate} onCancel={handleCancel} />}
|
||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>Colour</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{Object.entries(demoCustomFields).map(([key, { value }]) => (
|
{Object.entries(data).map(([key, { colour, label }]) => {
|
||||||
<tr key={key}>
|
return (
|
||||||
<td className={style.fullWidth}>{value}</td>
|
<CustomFieldEntry
|
||||||
<td className={style.actions}>
|
key={key}
|
||||||
<IconButton
|
colour={colour}
|
||||||
size='sm'
|
label={label}
|
||||||
variant='ontime-ghosted'
|
onEdit={handleEditField}
|
||||||
color='#e2e2e2' // $gray-200
|
onDelete={handleDelete}
|
||||||
icon={<IoPencil />}
|
/>
|
||||||
aria-label='Edit entry'
|
);
|
||||||
/>
|
})}
|
||||||
<IconButton
|
|
||||||
size='sm'
|
|
||||||
variant='ontime-ghosted'
|
|
||||||
color='#FA5656' // $red-500
|
|
||||||
icon={<IoTrash />}
|
|
||||||
aria-label='Delete entry'
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</Panel.Table>
|
</Panel.Table>
|
||||||
</Panel.Card>
|
</Panel.Card>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useRef } from 'react';
|
import { ChangeEvent, useEffect, useState } from 'react';
|
||||||
import { Button, Input, Select } from '@chakra-ui/react';
|
import { Button, Input } from '@chakra-ui/react';
|
||||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||||
import { IoCloudDownloadOutline } from '@react-icons/all-files/io5/IoCloudDownloadOutline';
|
|
||||||
import { IoShieldCheckmarkOutline } from '@react-icons/all-files/io5/IoShieldCheckmarkOutline';
|
import { IoShieldCheckmarkOutline } from '@react-icons/all-files/io5/IoShieldCheckmarkOutline';
|
||||||
|
|
||||||
|
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||||
|
import { openLink } from '../../../../common/utils/linkUtils';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
import useGoogleSheet from './useGoogleSheet';
|
import useGoogleSheet from './useGoogleSheet';
|
||||||
@@ -12,140 +13,177 @@ import { useSheetStore } from './useSheetStore';
|
|||||||
import style from './SourcesPanel.module.scss';
|
import style from './SourcesPanel.module.scss';
|
||||||
|
|
||||||
interface GSheetSetupProps {
|
interface GSheetSetupProps {
|
||||||
cancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GSheetSetup({ cancel }: GSheetSetupProps) {
|
export default function GSheetSetup({ onCancel }: GSheetSetupProps) {
|
||||||
const { handleClientSecret, handleAuthenticate, handleConnect } = useGoogleSheet();
|
const { revoke, connect, verifyAuth } = useGoogleSheet();
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const sheetIdInputRef = useRef<HTMLInputElement>(null);
|
const [authKey, setAuthKey] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState<'' | 'cancel' | 'connect' | 'authenticate'>('');
|
||||||
const stepData = useSheetStore((state) => state.stepData);
|
const [authLink, setAuthLink] = useState('');
|
||||||
const reset = useSheetStore((state) => state.reset);
|
|
||||||
|
|
||||||
const sheetId = useSheetStore((state) => state.sheetId);
|
const sheetId = useSheetStore((state) => state.sheetId);
|
||||||
const worksheetOptions = useSheetStore((state) => state.worksheetOptions) ?? [];
|
|
||||||
|
|
||||||
const setWorksheet = useSheetStore((state) => state.setWorksheet);
|
|
||||||
const setSheetId = useSheetStore((state) => state.setSheetId);
|
const setSheetId = useSheetStore((state) => state.setSheetId);
|
||||||
|
|
||||||
const worksheetIdInputRef = useRef<HTMLSelectElement>(null);
|
const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
|
||||||
|
const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus);
|
||||||
|
|
||||||
|
/** Check if we are authenticated */
|
||||||
|
const getAuthStatus = async () => {
|
||||||
|
const result = await verifyAuth();
|
||||||
|
if (result) {
|
||||||
|
setAuthenticationStatus(result.authenticated);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** check if the current session has been authenticated */
|
||||||
|
useEffect(() => {
|
||||||
|
getAuthStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCancelFlow = () => {
|
||||||
|
revoke();
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
// user cancels the flow
|
// user cancels the flow
|
||||||
const onCancel = () => {
|
const handleRevoke = async () => {
|
||||||
reset();
|
setLoading('cancel');
|
||||||
cancel();
|
await revoke();
|
||||||
|
await getAuthStatus();
|
||||||
|
setLoading('');
|
||||||
};
|
};
|
||||||
|
|
||||||
// connect to the accoutn with the given sheet ID
|
/**
|
||||||
const connectToId = () => {
|
* Gets file from input
|
||||||
const sheetId = sheetIdInputRef.current?.value;
|
* @param event
|
||||||
|
*/
|
||||||
|
const handleClientSecret = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!event.target.files?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFile(event.target.files[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests connection to google auth
|
||||||
|
*/
|
||||||
|
const handleConnect = async () => {
|
||||||
|
if (!file) return;
|
||||||
if (!sheetId) return;
|
if (!sheetId) return;
|
||||||
|
|
||||||
handleConnect(sheetId);
|
setLoading('connect');
|
||||||
|
const result = await connect(file, sheetId);
|
||||||
|
if (result) {
|
||||||
|
setAuthLink(result.verification_url);
|
||||||
|
setAuthKey(result.user_code);
|
||||||
|
}
|
||||||
|
setLoading('');
|
||||||
};
|
};
|
||||||
|
|
||||||
// adds the user input sheet ID to the store
|
/**
|
||||||
const addSheetId = () => {
|
* Open google auth
|
||||||
const sheetId = sheetIdInputRef.current?.value;
|
*/
|
||||||
console.log('adding', sheetId);
|
const handleAuthenticate = async () => {
|
||||||
if (!sheetId) return;
|
setLoading('authenticate');
|
||||||
setSheetId(sheetId);
|
|
||||||
|
// open link and schedule a check for when the user focuses again
|
||||||
|
openLink(authLink);
|
||||||
|
window.addEventListener(
|
||||||
|
'focus',
|
||||||
|
async () => {
|
||||||
|
getAuthStatus();
|
||||||
|
setLoading('');
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// adds the selected worksheet to the store
|
const canConnect = file && sheetId;
|
||||||
const addWorksheetSheetId = () => {
|
const canAuthenticate = Boolean(authKey) && Boolean(authLink);
|
||||||
const worksheetId = worksheetIdInputRef.current?.value;
|
const isLoading = Boolean(loading);
|
||||||
if (!worksheetId) return;
|
const isAuthenticated = authenticationStatus === 'authenticated';
|
||||||
setWorksheet(worksheetId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const canAuthenticate = stepData.authenticate.available;
|
|
||||||
const canConnect = stepData.authenticate.available && sheetId;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Title>
|
<Panel.Title>
|
||||||
Sync with Google Sheet (experimental)
|
Sync with Google Sheet (experimental)
|
||||||
<Button variant='ontime-subtle' size='sm' onClick={onCancel}>
|
<Button variant='ontime-subtle' size='sm' onClick={handleCancelFlow}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.Title>
|
</Panel.Title>
|
||||||
<Panel.ListGroup>
|
{isAuthenticated ? (
|
||||||
<div className={style.buttonRow}>
|
<Panel.ListGroup>
|
||||||
<div className={style.inputContainer}>
|
<Panel.Title>Authenticated</Panel.Title>
|
||||||
<Input type='file' onChange={handleClientSecret} accept='.json' size='sm' variant='ontime-filled' />
|
<Button variant='ontime-subtle' size='sm' onClick={handleRevoke} isLoading={loading === 'cancel'}>
|
||||||
</div>
|
Revoke Authentication
|
||||||
<Button
|
|
||||||
variant='ontime-subtle'
|
|
||||||
size='sm'
|
|
||||||
onClick={handleAuthenticate}
|
|
||||||
leftIcon={<IoShieldCheckmarkOutline />}
|
|
||||||
isDisabled={!canAuthenticate}
|
|
||||||
>
|
|
||||||
Authenticate
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</Panel.ListGroup>
|
||||||
<Panel.Error>{stepData.clientSecret.error}</Panel.Error>
|
) : (
|
||||||
</Panel.ListGroup>
|
<>
|
||||||
|
<Panel.ListGroup>
|
||||||
|
<Panel.Description>Upload Client Secret provided by Google</Panel.Description>
|
||||||
|
<Panel.Error>{undefined}</Panel.Error>
|
||||||
|
<Input
|
||||||
|
type='file'
|
||||||
|
onChange={handleClientSecret}
|
||||||
|
accept='.json'
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-filled'
|
||||||
|
isDisabled={isLoading || canAuthenticate}
|
||||||
|
/>
|
||||||
|
</Panel.ListGroup>
|
||||||
|
|
||||||
<Panel.ListGroup>
|
<Panel.ListGroup>
|
||||||
<Panel.Error>{stepData.sheetId.error}</Panel.Error>
|
<Panel.Description>Enter ID of sheet to synchronise</Panel.Description>
|
||||||
<div className={style.buttonRow}>
|
<Panel.Error>{undefined}</Panel.Error>
|
||||||
<div className={style.inputContainer}>
|
|
||||||
<Input
|
<Input
|
||||||
size='sm'
|
size='sm'
|
||||||
variant='ontime-filled'
|
variant='ontime-filled'
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
isDisabled={!stepData.sheetId.available}
|
placeholder='Sheet ID'
|
||||||
placeholder='Enter Sheet ID'
|
onChange={(event) => setSheetId(event.target.value)}
|
||||||
onBlur={addSheetId}
|
isDisabled={isLoading || canAuthenticate}
|
||||||
onSubmit={addSheetId}
|
|
||||||
ref={sheetIdInputRef}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Panel.ListGroup>
|
||||||
<Button
|
|
||||||
variant='ontime-subtle'
|
|
||||||
size='sm'
|
|
||||||
onClick={connectToId}
|
|
||||||
isDisabled={!canConnect}
|
|
||||||
leftIcon={<IoCheckmark />}
|
|
||||||
>
|
|
||||||
Connect
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Panel.ListGroup>
|
|
||||||
|
|
||||||
<Panel.ListGroup>
|
{!canAuthenticate ? (
|
||||||
<div className={style.buttonRow}>
|
<Panel.ListGroup>
|
||||||
<div className={style.inputContainer}>
|
<div className={style.buttonRow}>
|
||||||
<Select
|
<Button
|
||||||
size='sm'
|
variant='ontime-subtle'
|
||||||
variant='ontime'
|
size='sm'
|
||||||
isDisabled={!stepData.worksheet.available}
|
leftIcon={<IoCheckmark />}
|
||||||
placeholder='Select worksheet'
|
onClick={handleConnect}
|
||||||
ref={worksheetIdInputRef}
|
isDisabled={!canConnect || isLoading}
|
||||||
>
|
isLoading={loading === 'connect'}
|
||||||
{worksheetOptions.map((value) => (
|
>
|
||||||
<option key={value} value={value}>
|
Connect
|
||||||
{value}
|
</Button>
|
||||||
</option>
|
</div>
|
||||||
))}
|
</Panel.ListGroup>
|
||||||
</Select>
|
) : (
|
||||||
</div>
|
<Panel.ListGroup>
|
||||||
<Button
|
<div className={style.buttonRow}>
|
||||||
variant='ontime-filled'
|
<CopyTag label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
|
||||||
size='sm'
|
{authKey ? authKey : 'Upload files to generate Auth Key'}
|
||||||
onClick={addWorksheetSheetId}
|
</CopyTag>
|
||||||
isDisabled={!stepData.worksheet.available}
|
<Button
|
||||||
leftIcon={<IoCloudDownloadOutline />}
|
variant='ontime-filled'
|
||||||
>
|
size='sm'
|
||||||
Continue
|
leftIcon={<IoShieldCheckmarkOutline />}
|
||||||
</Button>
|
onClick={handleAuthenticate}
|
||||||
</div>
|
isDisabled={!canAuthenticate || isLoading}
|
||||||
<Panel.Error>{stepData.worksheet.error}</Panel.Error>
|
isLoading={loading === 'authenticate'}
|
||||||
<Panel.Error>{stepData.pullPush.error}</Panel.Error>
|
>
|
||||||
</Panel.ListGroup>
|
Authenticate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Panel.ListGroup>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { Button } from '@chakra-ui/react';
|
import { Button } from '@chakra-ui/react';
|
||||||
|
|
||||||
import ExcelFileOptions from '../../../modals/upload-modal/upload-options/ExcelFileOptions';
|
import ExcelFileOptions from '../../../modals/upload-modal/upload-options/ExcelFileOptions';
|
||||||
@@ -9,34 +10,53 @@ import { useSheetStore } from './useSheetStore';
|
|||||||
import style from './SourcesPanel.module.scss';
|
import style from './SourcesPanel.module.scss';
|
||||||
|
|
||||||
export default function ImportMap() {
|
export default function ImportMap() {
|
||||||
const { handleImportPreview, handleExport } = useGoogleSheet();
|
const { importRundownPreview, exportRundown } = useGoogleSheet();
|
||||||
|
|
||||||
const sheetId = useSheetStore((state) => state.sheetId);
|
|
||||||
const worksheetId = useSheetStore((state) => state.worksheet);
|
|
||||||
const importOptions = useSheetStore((state) => state.excelFileOptions);
|
const importOptions = useSheetStore((state) => state.excelFileOptions);
|
||||||
const patchImportOptions = useSheetStore((state) => state.patchExcelFileOptions);
|
const patchImportOptions = useSheetStore((state) => state.patchExcelFileOptions);
|
||||||
const stepData = useSheetStore((state) => state.stepData);
|
const stepData = useSheetStore((state) => state.stepData);
|
||||||
|
const sheetId = useSheetStore((state) => state.sheetId);
|
||||||
|
|
||||||
const exportRundown = () => {
|
const [loading, setLoading] = useState<'' | 'export' | 'import'>('');
|
||||||
if (!worksheetId || !sheetId) return;
|
|
||||||
handleExport(sheetId, worksheetId, importOptions);
|
const handleExport = async () => {
|
||||||
|
if (!sheetId) return;
|
||||||
|
setLoading('export');
|
||||||
|
await exportRundown(sheetId, importOptions);
|
||||||
|
setLoading('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const importPreviewRundown = () => {
|
const handleImportPreview = async () => {
|
||||||
if (!worksheetId || !sheetId) return;
|
if (!sheetId) return;
|
||||||
handleImportPreview(sheetId, worksheetId, importOptions);
|
setLoading('import');
|
||||||
|
await importRundownPreview(sheetId, importOptions);
|
||||||
|
setLoading('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isLoading = Boolean(loading);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Title>Import options</Panel.Title>
|
<Panel.Title>Import options</Panel.Title>
|
||||||
<ExcelFileOptions importOptions={importOptions} updateOptions={patchImportOptions} />
|
<ExcelFileOptions importOptions={importOptions} updateOptions={patchImportOptions} />
|
||||||
<Panel.Error>{stepData.worksheet.error}</Panel.Error>
|
<Panel.Error>{stepData.worksheet.error}</Panel.Error>
|
||||||
<div className={style.buttonRow}>
|
<div className={style.buttonRow}>
|
||||||
<Button variant='ontime-filled' size='sm' onClick={exportRundown}>
|
<Button
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='sm'
|
||||||
|
onClick={handleExport}
|
||||||
|
isDisabled={isLoading || !sheetId}
|
||||||
|
isLoading={loading === 'export'}
|
||||||
|
>
|
||||||
Export
|
Export
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='ontime-filled' size='sm' onClick={importPreviewRundown}>
|
<Button
|
||||||
|
variant='ontime-filled'
|
||||||
|
size='sm'
|
||||||
|
onClick={handleImportPreview}
|
||||||
|
isDisabled={isLoading || !sheetId}
|
||||||
|
isLoading={loading === 'import'}
|
||||||
|
>
|
||||||
Import preview
|
Import preview
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ interface ImportReviewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ImportReview({ rundown, userFields }: ImportReviewProps) {
|
export default function ImportReview({ rundown, userFields }: ImportReviewProps) {
|
||||||
const { handleImport } = useGoogleSheet();
|
const { importRundown } = useGoogleSheet();
|
||||||
const resetPreview = useSheetStore((state) => state.resetPreview);
|
const resetPreview = useSheetStore((state) => state.resetPreview);
|
||||||
|
|
||||||
const applyImport = () => {
|
const applyImport = () => {
|
||||||
handleImport(rundown, userFields);
|
importRundown(rundown, userFields);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
.buttonRow {
|
.buttonRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
justify-content: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inputContainer {
|
.inputContainer {
|
||||||
|
|||||||
@@ -16,14 +16,16 @@ import style from './SourcesPanel.module.scss';
|
|||||||
export default function SourcesPanel() {
|
export default function SourcesPanel() {
|
||||||
const [importFlow, setImportFlow] = useState<'none' | 'excel' | 'gsheet'>('none');
|
const [importFlow, setImportFlow] = useState<'none' | 'excel' | 'gsheet'>('none');
|
||||||
|
|
||||||
const hasDataSource = useSheetStore((state) => state.stepData.worksheet.available);
|
const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
|
||||||
const rundown = useSheetStore((state) => state.rundown);
|
const rundown = useSheetStore((state) => state.rundown);
|
||||||
const userFields = useSheetStore((state) => state.userFields);
|
const userFields = useSheetStore((state) => state.userFields);
|
||||||
|
|
||||||
|
const isAuthenticated = authenticationStatus === 'authenticated';
|
||||||
const hasData = rundown && userFields;
|
const hasData = rundown && userFields;
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleFile = () => console.error('not yet implementeed');
|
const handleFile = () => console.error('not yet implemented');
|
||||||
|
|
||||||
const handleUpload = () => {
|
const handleUpload = () => {
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
@@ -73,9 +75,9 @@ export default function SourcesPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isGSheetFlow && <GSheetSetup cancel={cancelGSheetFlow} />}
|
{isGSheetFlow && <GSheetSetup onCancel={cancelGSheetFlow} />}
|
||||||
{isExcelFlow && <Panel.Title>Not yet implemented</Panel.Title>}
|
{isExcelFlow && <Panel.Title>Not yet implemented</Panel.Title>}
|
||||||
{hasDataSource && <ImportMap />}
|
{isAuthenticated && <ImportMap />}
|
||||||
{hasData && <ImportReview rundown={rundown} userFields={userFields} />}
|
{hasData && <ImportReview rundown={rundown} userFields={userFields} />}
|
||||||
</Panel.Card>
|
</Panel.Card>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
|
|||||||
@@ -1,111 +1,60 @@
|
|||||||
import { ChangeEvent } from 'react';
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { OntimeRundown, UserFields } from 'ontime-types';
|
import { AuthenticationStatus, OntimeRundown, UserFields } from 'ontime-types';
|
||||||
import { ExcelImportMap } from 'ontime-utils';
|
import { ExcelImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
import { RUNDOWN, USERFIELDS } from '../../../../common/api/apiConstants';
|
import { RUNDOWN, USERFIELDS } from '../../../../common/api/apiConstants';
|
||||||
import { maybeAxiosError } from '../../../../common/api/apiUtils';
|
import { maybeAxiosError } from '../../../../common/api/apiUtils';
|
||||||
import {
|
import {
|
||||||
getAuthentication,
|
|
||||||
getClientSecret,
|
|
||||||
getSheetsAuthUrl,
|
|
||||||
patchData,
|
patchData,
|
||||||
postId,
|
previewRundown,
|
||||||
postPreviewSheet,
|
requestConnection,
|
||||||
postPushSheet,
|
revokeAuthentication,
|
||||||
postWorksheet,
|
uploadRundown,
|
||||||
uploadSheetClientFile,
|
verifyAuthenticationStatus,
|
||||||
} from '../../../../common/api/ontimeApi';
|
} from '../../../../common/api/ontimeApi';
|
||||||
import { openLink } from '../../../../common/utils/linkUtils';
|
|
||||||
|
|
||||||
import { useSheetStore } from './useSheetStore';
|
import { useSheetStore } from './useSheetStore';
|
||||||
|
|
||||||
// TODO: recover useEffect for resuming previous state
|
|
||||||
export default function useGoogleSheet() {
|
export default function useGoogleSheet() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// functions push data to store
|
// functions push data to store
|
||||||
const setClientSecret = useSheetStore((state) => state.setClientSecret);
|
|
||||||
const patchStepData = useSheetStore((state) => state.patchStepData);
|
const patchStepData = useSheetStore((state) => state.patchStepData);
|
||||||
const setSheetId = useSheetStore((state) => state.setSheetId);
|
|
||||||
const setWorksheetOptions = useSheetStore((state) => state.setWorksheetOptions);
|
|
||||||
const setRundown = useSheetStore((state) => state.setRundown);
|
const setRundown = useSheetStore((state) => state.setRundown);
|
||||||
const setUserFields = useSheetStore((state) => state.setUserFields);
|
const setUserFields = useSheetStore((state) => state.setUserFields);
|
||||||
|
|
||||||
/** receives a client secrets file and passes on to the server */
|
/** whether the current session has been authenticated */
|
||||||
const handleClientSecret = async (event: ChangeEvent<HTMLInputElement>) => {
|
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus } | void> => {
|
||||||
if (!event.target.files?.length) {
|
|
||||||
patchStepData({
|
|
||||||
clientSecret: { available: true, error: 'Missing file' },
|
|
||||||
authenticate: { available: false, error: '' },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const selectedFile = event.target.files[0];
|
return verifyAuthenticationStatus();
|
||||||
await uploadSheetClientFile(selectedFile);
|
} catch (_error) {
|
||||||
// TODO: why do we need this call?
|
/** we do not handle errors here */
|
||||||
await getClientSecret();
|
|
||||||
setClientSecret(selectedFile);
|
|
||||||
patchStepData({
|
|
||||||
clientSecret: { available: true, error: '' },
|
|
||||||
authenticate: { available: true, error: '' },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
patchStepData({
|
|
||||||
clientSecret: { available: true, error: maybeAxiosError(error) },
|
|
||||||
authenticate: { available: false, error: '' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** authenticate with the Google Sheets API */
|
/** requests connection to a google sheet */
|
||||||
const handleAuthenticate = async () => {
|
const connect = async (
|
||||||
|
file: File,
|
||||||
|
sheetId: string,
|
||||||
|
): Promise<{ verification_url: string; user_code: string } | void> => {
|
||||||
try {
|
try {
|
||||||
const authLink = await getSheetsAuthUrl();
|
return requestConnection(file, sheetId);
|
||||||
|
} catch (_error) {
|
||||||
// request window to open link and check auth when user is back
|
/** we do not handle errors here */
|
||||||
openLink(authLink);
|
|
||||||
window.addEventListener('focus', async () => await getAuthentication(), { once: true });
|
|
||||||
|
|
||||||
patchStepData({
|
|
||||||
authenticate: { available: true, error: '' },
|
|
||||||
sheetId: { available: true, error: '' },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
patchStepData({
|
|
||||||
authenticate: { available: true, error: maybeAxiosError(error) },
|
|
||||||
sheetId: { available: false, error: '' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** fetches data from a Google Sheet by its ID */
|
const revoke = async (): Promise<{ authenticated: AuthenticationStatus } | void> => {
|
||||||
const handleConnect = async (sheetId: string) => {
|
|
||||||
try {
|
try {
|
||||||
setSheetId(sheetId);
|
return revokeAuthentication();
|
||||||
const data = await postId(sheetId);
|
} catch (_error) {
|
||||||
setWorksheetOptions(data.worksheetOptions);
|
/** we do not handle errors here */
|
||||||
patchStepData({ worksheet: { available: true, error: '' } });
|
|
||||||
} catch (error) {
|
|
||||||
patchStepData({
|
|
||||||
sheetId: { available: true, error: maybeAxiosError(error) },
|
|
||||||
worksheet: { available: false, error: '' },
|
|
||||||
pullPush: { available: false, error: '' },
|
|
||||||
});
|
|
||||||
setWorksheetOptions([]);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** fetches data from a worksheet by its ID */
|
/** fetches data from a worksheet by its ID */
|
||||||
const handleImportPreview = async (sheetId: string, worksheet: string, fileOptions: ExcelImportMap) => {
|
const importRundownPreview = async (sheetId: string, fileOptions: ExcelImportMap) => {
|
||||||
try {
|
try {
|
||||||
// update worksheet data in the server
|
const data = await previewRundown(sheetId, fileOptions);
|
||||||
await postWorksheet(sheetId, worksheet);
|
|
||||||
|
|
||||||
// get data from google
|
|
||||||
const data = await postPreviewSheet(sheetId, fileOptions);
|
|
||||||
setRundown(data.rundown);
|
setRundown(data.rundown);
|
||||||
setUserFields(data.userFields);
|
setUserFields(data.userFields);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -114,13 +63,10 @@ export default function useGoogleSheet() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** writes data to a worksheet by its ID */
|
/** writes data to a worksheet by its ID */
|
||||||
const handleExport = async (sheetId: string, worksheet: string, fileOptions: ExcelImportMap) => {
|
const exportRundown = async (sheetId: string, fileOptions: ExcelImportMap) => {
|
||||||
try {
|
try {
|
||||||
// update worksheet data in the server
|
|
||||||
await postWorksheet(sheetId, worksheet);
|
|
||||||
|
|
||||||
// write data to google
|
// write data to google
|
||||||
await postPushSheet(sheetId, fileOptions);
|
await uploadRundown(sheetId, fileOptions);
|
||||||
patchStepData({ pullPush: { available: false, error: '' } });
|
patchStepData({ pullPush: { available: false, error: '' } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
|
patchStepData({ pullPush: { available: true, error: maybeAxiosError(error) } });
|
||||||
@@ -128,11 +74,11 @@ export default function useGoogleSheet() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** applies rundown and userfields to current project */
|
/** applies rundown and userfields to current project */
|
||||||
const handleImport = async (rundown: OntimeRundown, userFields: UserFields) => {
|
const importRundown = async (rundown: OntimeRundown, userFields: UserFields) => {
|
||||||
try {
|
try {
|
||||||
await patchData({ rundown, userFields });
|
await patchData({ rundown, userFields });
|
||||||
queryClient.setQueryData(RUNDOWN, rundown);
|
// we are unable to optimistically set the rundown since we need
|
||||||
queryClient.setQueryData(USERFIELDS, userFields);
|
// it to be normalised
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: [...RUNDOWN, ...USERFIELDS],
|
queryKey: [...RUNDOWN, ...USERFIELDS],
|
||||||
});
|
});
|
||||||
@@ -142,11 +88,12 @@ export default function useGoogleSheet() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleClientSecret,
|
connect,
|
||||||
handleAuthenticate,
|
revoke,
|
||||||
handleConnect,
|
verifyAuth,
|
||||||
handleImportPreview,
|
|
||||||
handleImport,
|
importRundownPreview,
|
||||||
handleExport,
|
importRundown,
|
||||||
|
exportRundown,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,36 @@
|
|||||||
import { OntimeRundown, UserFields } from 'ontime-types';
|
import { AuthenticationStatus, OntimeRundown, UserFields } from 'ontime-types';
|
||||||
import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils';
|
import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
// TODO: persist excelFileOptions to localStorage
|
// TODO: persist excelFileOptions to localStorage
|
||||||
|
|
||||||
type SheetStore = {
|
type SheetStore = {
|
||||||
clientSecret: File | null;
|
|
||||||
rundown: OntimeRundown | null;
|
|
||||||
userFields: UserFields | null;
|
|
||||||
sheetId: string | null;
|
|
||||||
worksheet: string | null;
|
|
||||||
worksheetOptions: string[] | null;
|
|
||||||
excelFileOptions: ExcelImportMap;
|
|
||||||
stepData: typeof initialStepData;
|
stepData: typeof initialStepData;
|
||||||
setClientSecret: (clientSecret: File | null) => void;
|
|
||||||
setRundown: (rundown: OntimeRundown | null) => void;
|
|
||||||
setUserFields: (userFields: UserFields | null) => void;
|
|
||||||
setSheetId: (sheetId: string) => void;
|
|
||||||
setWorksheet: (worksheet: string) => void;
|
|
||||||
setWorksheetOptions: (worksheetOptions: string[] | null) => void;
|
|
||||||
patchExcelFileOptions: <T extends keyof ExcelImportMap>(field: T, value: ExcelImportMap[T]) => void;
|
|
||||||
patchStepData: (patch: Partial<typeof initialStepData>) => void;
|
patchStepData: (patch: Partial<typeof initialStepData>) => void;
|
||||||
|
|
||||||
|
sheetId: string | null;
|
||||||
|
setSheetId: (sheetId: string | null) => void;
|
||||||
|
|
||||||
|
authenticationStatus: AuthenticationStatus;
|
||||||
|
setAuthenticationStatus: (status: AuthenticationStatus) => void;
|
||||||
|
|
||||||
|
rundown: OntimeRundown | null;
|
||||||
|
setRundown: (rundown: OntimeRundown | null) => void;
|
||||||
|
|
||||||
|
userFields: UserFields | null;
|
||||||
|
setUserFields: (userFields: UserFields | null) => void;
|
||||||
|
|
||||||
|
worksheetOptions: string[] | null;
|
||||||
|
setWorksheetOptions: (worksheetOptions: string[] | null) => void;
|
||||||
|
|
||||||
|
excelFileOptions: ExcelImportMap;
|
||||||
|
patchExcelFileOptions: <T extends keyof ExcelImportMap>(field: T, value: ExcelImportMap[T]) => void;
|
||||||
|
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
resetPreview: () => void;
|
resetPreview: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialStepData = {
|
const initialStepData = {
|
||||||
clientSecret: { available: true, error: '' },
|
|
||||||
authenticate: { available: false, error: '' },
|
authenticate: { available: false, error: '' },
|
||||||
sheetId: { available: false, error: '' },
|
sheetId: { available: false, error: '' },
|
||||||
worksheet: { available: false, error: '' },
|
worksheet: { available: false, error: '' },
|
||||||
@@ -34,34 +38,40 @@ const initialStepData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
clientSecret: null,
|
stepData: initialStepData,
|
||||||
|
sheetId: null,
|
||||||
|
authenticationStatus: 'not_authenticated' as AuthenticationStatus,
|
||||||
rundown: null,
|
rundown: null,
|
||||||
userFields: null,
|
userFields: null,
|
||||||
sheetId: null,
|
|
||||||
worksheet: null,
|
|
||||||
worksheetOptions: null,
|
worksheetOptions: null,
|
||||||
excelFileOptions: defaultExcelImportMap,
|
excelFileOptions: defaultExcelImportMap,
|
||||||
stepData: initialStepData,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSheetStore = create<SheetStore>((set, get) => ({
|
export const useSheetStore = create<SheetStore>((set, get) => ({
|
||||||
...initialState,
|
...initialState,
|
||||||
setClientSecret: (clientSecret: File | null) => set({ clientSecret }),
|
|
||||||
|
patchStepData: (patch: Partial<typeof initialStepData>) => {
|
||||||
|
const stepData = get().stepData;
|
||||||
|
set({ stepData: { ...stepData, ...patch } });
|
||||||
|
},
|
||||||
|
|
||||||
|
setSheetId: (sheetId: string | null) => set({ sheetId }),
|
||||||
|
|
||||||
|
setAuthenticationStatus: (status: AuthenticationStatus) => set({ authenticationStatus: status }),
|
||||||
|
|
||||||
setRundown: (rundown: OntimeRundown | null) => set({ rundown }),
|
setRundown: (rundown: OntimeRundown | null) => set({ rundown }),
|
||||||
|
|
||||||
setUserFields: (userFields: UserFields | null) => set({ userFields }),
|
setUserFields: (userFields: UserFields | null) => set({ userFields }),
|
||||||
setSheetId: (sheetId: string) => set({ sheetId }),
|
|
||||||
setWorksheet: (worksheet: string) => set({ worksheet }),
|
|
||||||
setWorksheetOptions: (worksheetOptions: string[] | null) => set({ worksheetOptions }),
|
setWorksheetOptions: (worksheetOptions: string[] | null) => set({ worksheetOptions }),
|
||||||
|
|
||||||
patchExcelFileOptions: <T extends keyof ExcelImportMap>(field: T, value: ExcelImportMap[T]) => {
|
patchExcelFileOptions: <T extends keyof ExcelImportMap>(field: T, value: ExcelImportMap[T]) => {
|
||||||
const excelFileOptions = get().excelFileOptions;
|
const excelFileOptions = get().excelFileOptions;
|
||||||
if (excelFileOptions[field] !== value) {
|
if (excelFileOptions[field] !== value) {
|
||||||
excelFileOptions[field] = value;
|
excelFileOptions[field] = value;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
patchStepData: (patch: Partial<typeof initialStepData>) => {
|
|
||||||
const stepData = get().stepData;
|
|
||||||
set({ stepData: { ...stepData, ...patch } });
|
|
||||||
},
|
|
||||||
reset: () => set(initialState),
|
reset: () => set(initialState),
|
||||||
resetPreview: () => set({ rundown: null, userFields: null }),
|
resetPreview: () => set({ rundown: null, userFields: null }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
|
import { Button } from '@chakra-ui/react';
|
||||||
|
import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
import CopyTag from '../../../common/components/copy-tag/CopyTag';
|
import CopyTag from '../../../common/components/copy-tag/CopyTag';
|
||||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||||
|
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||||
import useRundown from '../../../common/hooks-query/useRundown';
|
import useRundown from '../../../common/hooks-query/useRundown';
|
||||||
import { useEventSelection } from '../useEventSelection';
|
import { useEventSelection } from '../useEventSelection';
|
||||||
|
|
||||||
import EventEditorTimes from './composite/EventEditorTimes';
|
import EventEditorTimes from './composite/EventEditorTimes';
|
||||||
import EventEditorTitles from './composite/EventEditorTitles';
|
import EventEditorTitles from './composite/EventEditorTitles';
|
||||||
import EventEditorUser from './composite/EventEditorUser';
|
import EventEditorUser from './composite/EventEditorUser';
|
||||||
|
import EventTextArea from './composite/EventTextArea';
|
||||||
|
|
||||||
import style from './EventEditor.module.scss';
|
import style from './EventEditor.module.scss';
|
||||||
|
|
||||||
@@ -31,11 +34,13 @@ export type EditorUpdateFields =
|
|||||||
| 'user6'
|
| 'user6'
|
||||||
| 'user7'
|
| 'user7'
|
||||||
| 'user8'
|
| 'user8'
|
||||||
| 'user9';
|
| 'user9'
|
||||||
|
| CustomFieldLabel; // TODO: keyof customFields
|
||||||
|
|
||||||
export default function EventEditor() {
|
export default function EventEditor() {
|
||||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||||
const { data } = useRundown();
|
const { data } = useRundown();
|
||||||
|
const { data: customFields } = useCustomFields();
|
||||||
const { order, rundown } = data;
|
const { order, rundown } = data;
|
||||||
const { updateEvent } = useEventAction();
|
const { updateEvent } = useEventAction();
|
||||||
|
|
||||||
@@ -63,7 +68,12 @@ export default function EventEditor() {
|
|||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(field: EditorUpdateFields, value: string) => {
|
(field: EditorUpdateFields, value: string) => {
|
||||||
updateEvent({ id: event?.id, [field]: value });
|
if (field.startsWith('custom-')) {
|
||||||
|
const fieldLabel = field.split('custom-')[1];
|
||||||
|
updateEvent({ id: event?.id, custom: { [fieldLabel]: { value } } });
|
||||||
|
} else {
|
||||||
|
updateEvent({ id: event?.id, [field]: value });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[event?.id, updateEvent],
|
[event?.id, updateEvent],
|
||||||
);
|
);
|
||||||
@@ -91,6 +101,8 @@ export default function EventEditor() {
|
|||||||
user9: event.user9,
|
user9: event.user9,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const customKeys = Object.keys(customFields ?? {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.eventEditor} data-testid='editor-container'>
|
<div className={style.eventEditor} data-testid='editor-container'>
|
||||||
<div className={style.content}>
|
<div className={style.content}>
|
||||||
@@ -120,6 +132,25 @@ export default function EventEditor() {
|
|||||||
colour={event.colour}
|
colour={event.colour}
|
||||||
handleSubmit={handleSubmit}
|
handleSubmit={handleSubmit}
|
||||||
/>
|
/>
|
||||||
|
<div className={style.column}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span>Custom Fields</span>
|
||||||
|
<Button variant='ontime-subtle' size='sm' isDisabled>
|
||||||
|
Manage
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{customKeys.map((label) => {
|
||||||
|
return (
|
||||||
|
<EventTextArea
|
||||||
|
key={label}
|
||||||
|
field={`custom-${label}`}
|
||||||
|
label={label}
|
||||||
|
initialValue={event.custom[label]?.value ?? ''}
|
||||||
|
submitHandler={handleSubmit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<EventEditorUser key={`${event.id}-user`} userFields={userFields} handleSubmit={handleSubmit} />
|
<EventEditorUser key={`${event.id}-user`} userFields={userFields} handleSubmit={handleSubmit} />
|
||||||
</div>
|
</div>
|
||||||
<div className={style.footer}>
|
<div className={style.footer}>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import { restoreService } from './services/RestoreService.js';
|
|||||||
import { messageService } from './services/message-service/MessageService.js';
|
import { messageService } from './services/message-service/MessageService.js';
|
||||||
import { populateDemo } from './modules/loadDemo.js';
|
import { populateDemo } from './modules/loadDemo.js';
|
||||||
import { getState, updateRundownData } from './stores/runtimeState.js';
|
import { getState, updateRundownData } from './stores/runtimeState.js';
|
||||||
import { setRundown } from './services/rundown-service/RundownService.js';
|
import { initRundown } from './services/rundown-service/RundownService.js';
|
||||||
import { getPlayableEvents } from './services/rundown-service/rundownUtils.js';
|
import { getPlayableEvents } from './services/rundown-service/rundownUtils.js';
|
||||||
import { generateCrashReport } from './utils/generateCrashReport.js';
|
import { generateCrashReport } from './utils/generateCrashReport.js';
|
||||||
|
|
||||||
@@ -183,7 +183,8 @@ export const startServer = async () => {
|
|||||||
|
|
||||||
// initialise rundown service
|
// initialise rundown service
|
||||||
const persistedRundown = DataProvider.getRundown();
|
const persistedRundown = DataProvider.getRundown();
|
||||||
setRundown(persistedRundown);
|
const persistedCustomFields = DataProvider.getCustomFields();
|
||||||
|
initRundown(persistedRundown, persistedCustomFields);
|
||||||
|
|
||||||
// TODO: do this on the init of the runtime service
|
// TODO: do this on the init of the runtime service
|
||||||
updateRundownData(getPlayableEvents());
|
updateRundownData(getPlayableEvents());
|
||||||
@@ -274,6 +275,7 @@ export const shutdown = async (exitCode = 0) => {
|
|||||||
await restoreService.clear();
|
await restoreService.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Clear token
|
||||||
expressServer?.close();
|
expressServer?.close();
|
||||||
oscServer?.shutdown();
|
oscServer?.shutdown();
|
||||||
runtimeService.shutdown();
|
runtimeService.shutdown();
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
|
|
||||||
import { data, db } from '../../modules/loadDb.js';
|
import { data, db } from '../../modules/loadDb.js';
|
||||||
import { safeMerge } from './DataProvider.utils.js';
|
import { safeMerge } from './DataProvider.utils.js';
|
||||||
|
import { isProduction } from '../../setup.js';
|
||||||
|
|
||||||
export class DataProvider {
|
export class DataProvider {
|
||||||
static getData() {
|
static getData() {
|
||||||
@@ -109,6 +110,9 @@ export class DataProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async persist() {
|
static async persist() {
|
||||||
|
if (!isProduction) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await db.write();
|
await db.write();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ describe('safeMerge', () => {
|
|||||||
user9: 'existing user9',
|
user9: 'existing user9',
|
||||||
},
|
},
|
||||||
customFields: {
|
customFields: {
|
||||||
lighting: { type: 'string', label: 'lighting' },
|
lighting: { type: 'string', label: 'lighting', colour: 'red' },
|
||||||
vfx: { type: 'string', label: 'vfx' },
|
vfx: { type: 'string', label: 'vfx', colour: 'blue' },
|
||||||
},
|
},
|
||||||
osc: {
|
osc: {
|
||||||
portIn: 8888,
|
portIn: 8888,
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export const config = {
|
|||||||
directory: 'demo',
|
directory: 'demo',
|
||||||
filename: ['app.js', 'index.html', 'styles.css'],
|
filename: ['app.js', 'index.html', 'styles.css'],
|
||||||
},
|
},
|
||||||
|
sheets: {
|
||||||
|
directory: 'sheets',
|
||||||
|
},
|
||||||
restoreFile: 'ontime.restore',
|
restoreFile: 'ontime.restore',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { LogOrigin } from 'ontime-types';
|
|
||||||
import type {
|
import type {
|
||||||
Alias,
|
Alias,
|
||||||
DatabaseModel,
|
DatabaseModel,
|
||||||
@@ -32,14 +31,12 @@ import {
|
|||||||
} from '../setup.js';
|
} from '../setup.js';
|
||||||
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
|
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
|
||||||
import { httpIntegration } from '../services/integration-service/HttpIntegration.js';
|
import { httpIntegration } from '../services/integration-service/HttpIntegration.js';
|
||||||
import { logger } from '../classes/Logger.js';
|
|
||||||
import { notifyChanges, setRundown } from '../services/rundown-service/RundownService.js';
|
import { notifyChanges, setRundown } from '../services/rundown-service/RundownService.js';
|
||||||
import { getProjectFiles } from '../utils/getFileListFromFolder.js';
|
import { getProjectFiles } from '../utils/getFileListFromFolder.js';
|
||||||
import { configService } from '../services/ConfigService.js';
|
import { configService } from '../services/ConfigService.js';
|
||||||
import { deleteFile } from '../utils/parserUtils.js';
|
import { deleteFile } from '../utils/parserUtils.js';
|
||||||
import { validateProjectFiles } from './ontimeController.validate.js';
|
import { validateProjectFiles } from './ontimeController.validate.js';
|
||||||
import { dbModel } from '../models/dataModel.js';
|
import { dbModel } from '../models/dataModel.js';
|
||||||
import { sheet } from '../utils/sheetsAuth.js';
|
|
||||||
import { removeFileExtension } from '../utils/removeFileExtension.js';
|
import { removeFileExtension } from '../utils/removeFileExtension.js';
|
||||||
import type { OntimeError } from '../utils/backend.types.js';
|
import type { OntimeError } from '../utils/backend.types.js';
|
||||||
import { ensureJsonExtension } from '../utils/ensureJsonExtension.js';
|
import { ensureJsonExtension } from '../utils/ensureJsonExtension.js';
|
||||||
@@ -95,7 +92,7 @@ export type ParsingOptions = {
|
|||||||
/**
|
/**
|
||||||
* parse an uploaded file and apply its parsed objects
|
* parse an uploaded file and apply its parsed objects
|
||||||
* @param file
|
* @param file
|
||||||
* @param req
|
* @param _req
|
||||||
* @param res
|
* @param res
|
||||||
* @param [options]
|
* @param [options]
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
@@ -277,7 +274,6 @@ export const postSettings = async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Get view Settings
|
* @description Get view Settings
|
||||||
* @method GET
|
|
||||||
*/
|
*/
|
||||||
export const getViewSettings = async (_req: Request, res: Response) => {
|
export const getViewSettings = async (_req: Request, res: Response) => {
|
||||||
const views = DataProvider.getViewSettings();
|
const views = DataProvider.getViewSettings();
|
||||||
@@ -286,7 +282,6 @@ export const getViewSettings = async (_req: Request, res: Response) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Change view Settings
|
* @description Change view Settings
|
||||||
* @method POST
|
|
||||||
*/
|
*/
|
||||||
export const postViewSettings = async (req: Request, res: Response) => {
|
export const postViewSettings = async (req: Request, res: Response) => {
|
||||||
if (failEmptyObjects(req.body, res)) {
|
if (failEmptyObjects(req.body, res)) {
|
||||||
@@ -410,7 +405,7 @@ export const dbUpload = async (req: Request, res: Response) => {
|
|||||||
* uploads and parses an excel file
|
* uploads and parses an excel file
|
||||||
* @returns parsed result
|
* @returns parsed result
|
||||||
*/
|
*/
|
||||||
export async function previewExcel(req, res: Response) {
|
export async function previewExcel(req: Request, res: Response) {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
res.status(400).send({ message: 'File not found' });
|
res.status(400).send({ message: 'File not found' });
|
||||||
return;
|
return;
|
||||||
@@ -428,10 +423,10 @@ export async function previewExcel(req, res: Response) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves and lists all project files from the uploads directory.
|
* Retrieves and lists all project files from the uploads directory.
|
||||||
* @param req
|
* @param _req
|
||||||
* @param res
|
* @param res
|
||||||
*/
|
*/
|
||||||
export const listProjects: RequestHandler = async (_, res: Response<ProjectFileListResponse | ErrorResponse>) => {
|
export const listProjects: RequestHandler = async (_req, res: Response<ProjectFileListResponse | ErrorResponse>) => {
|
||||||
try {
|
try {
|
||||||
const fileList = await getProjectFiles();
|
const fileList = await getProjectFiles();
|
||||||
|
|
||||||
@@ -637,125 +632,3 @@ export const deleteProjectFile: RequestHandler = async (req: Request, res: Respo
|
|||||||
res.status(500).send({ message: String(error) });
|
res.status(500).send({ message: String(error) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// SHEET Functions
|
|
||||||
/**
|
|
||||||
* @description SETP-1 POST Client Secrect
|
|
||||||
* @returns parsed result
|
|
||||||
*/
|
|
||||||
export async function uploadSheetClientFile(req, res: Response) {
|
|
||||||
if (!req.file.path) {
|
|
||||||
res.status(400).send({ message: 'File not found' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const client = JSON.parse(fs.readFileSync(req.file.path as string, 'utf-8'));
|
|
||||||
await sheet.saveClientSecrets(client);
|
|
||||||
res.status(200).send('OK');
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).send({ message: String(error) });
|
|
||||||
}
|
|
||||||
fs.unlink(req.file.path, (err) => {
|
|
||||||
if (err) logger.error(LogOrigin.Server, err.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description STEP-1 GET Client Secret status
|
|
||||||
*/
|
|
||||||
export const getClientSecret = async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
// TODO: can we merge this with the previous?
|
|
||||||
const clientSecretExists = await sheet.testClientSecret();
|
|
||||||
if (clientSecretExists) {
|
|
||||||
res.status(200).send();
|
|
||||||
} else {
|
|
||||||
res.status(500).send({ message: 'The Client ID does not exist' });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).send({ message: String(error) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description STEP-2 GET sheet authentication url
|
|
||||||
*/
|
|
||||||
export async function getAuthenticationUrl(_req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const authUrl = await sheet.openAuthServer();
|
|
||||||
res.status(200).send(authUrl);
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).send({ message: String(error) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description STEP-2 GET sheet authentication status
|
|
||||||
*/
|
|
||||||
export const getAuthentication = async (_req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
await sheet.testAuthentication();
|
|
||||||
res.status(200).send();
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).send({ message: String(error) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description STEP-3 POST sheet id
|
|
||||||
* @returns list of worksheets
|
|
||||||
*/
|
|
||||||
export const postId = async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const { sheetId } = req.body;
|
|
||||||
if (sheetId.length < 40) {
|
|
||||||
res.status(400).send({ message: 'ID is usually 44 characters long' });
|
|
||||||
}
|
|
||||||
const state = await sheet.testSheetId(sheetId);
|
|
||||||
res.status(200).send(state);
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).send({ message: String(error) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description STEP-4 POST worksheet
|
|
||||||
*/
|
|
||||||
export const postWorksheet = async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const { sheetId, worksheet } = req.body;
|
|
||||||
const state = await sheet.testWorksheet(sheetId, worksheet);
|
|
||||||
res.status(200).send(state);
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).send({ message: String(error) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description STEP-5 POST download rundown to sheet
|
|
||||||
* @returns parsed result
|
|
||||||
*/
|
|
||||||
export async function pullSheet(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const { sheetId, options } = req.body;
|
|
||||||
console.log('starting');
|
|
||||||
const data = await sheet.pull(sheetId, options);
|
|
||||||
console.log('finished');
|
|
||||||
res.status(200).send(data);
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).send({ message: String(error) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description STEP-5 POST upload rundown to sheet
|
|
||||||
*/
|
|
||||||
export async function pushSheet(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const { sheetId, options } = req.body;
|
|
||||||
await sheet.push(sheetId, options);
|
|
||||||
res.status(200).send();
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).send({ message: String(error) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -191,28 +191,6 @@ export const validateProjectRename = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Validates the filename for creating a project file.
|
|
||||||
*/
|
|
||||||
export const validateProjectCreate = [
|
|
||||||
body('filename')
|
|
||||||
.exists()
|
|
||||||
.withMessage('Filename is required')
|
|
||||||
.isString()
|
|
||||||
.withMessage('Filename must be a string')
|
|
||||||
.isLength({ min: 1, max: 255 })
|
|
||||||
.withMessage('Filename must be between 1 and 255 characters'),
|
|
||||||
|
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
|
||||||
const errors = validationResult(req);
|
|
||||||
if (!errors.isEmpty()) {
|
|
||||||
return res.status(422).json({ errors: errors.array() });
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Validates the existence of project files.
|
* @description Validates the existence of project files.
|
||||||
* @param {object} projectFiles
|
* @param {object} projectFiles
|
||||||
@@ -243,35 +221,3 @@ export const validateProjectFiles = (projectFiles: { filename?: string; newFilen
|
|||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const validateSheetId = [
|
|
||||||
body('sheetId').exists().isString(),
|
|
||||||
|
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
|
||||||
const errors = validationResult(req);
|
|
||||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
|
||||||
next();
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const validateWorksheet = [
|
|
||||||
body('sheetId').exists().isString(),
|
|
||||||
body('worksheet').exists().isString(),
|
|
||||||
|
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
|
||||||
const errors = validationResult(req);
|
|
||||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
|
||||||
next();
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const validateSheetOptions = [
|
|
||||||
body('sheetId').exists().isString(),
|
|
||||||
// body('options').exists().isObject(), TODO:
|
|
||||||
|
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
|
||||||
const errors = validationResult(req);
|
|
||||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
|
||||||
next();
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import { CustomField, CustomFields, ProjectData } from 'ontime-types';
|
|||||||
import { removeUndefined } from '../utils/parserUtils.js';
|
import { removeUndefined } from '../utils/parserUtils.js';
|
||||||
import { failEmptyObjects } from '../utils/routerUtils.js';
|
import { failEmptyObjects } from '../utils/routerUtils.js';
|
||||||
import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
||||||
import { createCustomField, editCustomField, removeCustomField } from '../utils/customFields.js';
|
import {
|
||||||
|
createCustomField,
|
||||||
|
editCustomField,
|
||||||
|
getCustomFields as getCustomFieldsFromCache,
|
||||||
|
removeCustomField,
|
||||||
|
} from '../services/rundown-service/rundownCache.js';
|
||||||
|
|
||||||
// Create controller for GET request to 'project'
|
// Create controller for GET request to 'project'
|
||||||
export const getProject: RequestHandler = async (req, res) => {
|
export const getProject: RequestHandler = async (req, res) => {
|
||||||
@@ -36,15 +41,12 @@ export const postProject: RequestHandler = async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getCustomFields: RequestHandler = async (_req: Request, res: Response<CustomFields>) => {
|
export const getCustomFields: RequestHandler = async (_req: Request, res: Response<CustomFields>) => {
|
||||||
res.json(DataProvider.getCustomFields());
|
const customFields = getCustomFieldsFromCache();
|
||||||
|
res.json(customFields);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Expects { label: <lable> type: 'string | ..' }
|
// Expects { label: <label> type: 'string | ..' }
|
||||||
export const postCustomField: RequestHandler = async (req: Request, res: Response) => {
|
export const postCustomField: RequestHandler = async (req: Request, res: Response) => {
|
||||||
if (failEmptyObjects(req.body, res)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newField = req.body as CustomField;
|
const newField = req.body as CustomField;
|
||||||
const allFields = await createCustomField(newField);
|
const allFields = await createCustomField(newField);
|
||||||
@@ -54,21 +56,19 @@ export const postCustomField: RequestHandler = async (req: Request, res: Respons
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Expects { label: <oldLable>, field: { label: <newlable> type: 'string | ..' } }
|
// Expects { label: <oldLabel>, field: { label: <newLabel> type: 'string | ..' } }
|
||||||
export const putCustomField: RequestHandler = async (req: Request, res: Response) => {
|
export const putCustomField: RequestHandler = async (req: Request, res: Response) => {
|
||||||
if (failEmptyObjects(req.body, res)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newFields = await editCustomField(req.body.label, req.body.field);
|
const oldLabel = req.params.label;
|
||||||
|
const { colour, type, label } = req.body;
|
||||||
|
const newFields = await editCustomField(oldLabel, { label, colour, type });
|
||||||
res.status(200).send(newFields);
|
res.status(200).send(newFields);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).send({ message: error.toString() });
|
res.status(400).send({ message: error.toString() });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Expects { label: <lable> }
|
// Expects { label: <label> }
|
||||||
export const deleteCustomField: RequestHandler = async (req: Request, res: Response) => {
|
export const deleteCustomField: RequestHandler = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const fieldToDelete = req.params.label;
|
const fieldToDelete = req.params.label;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { body, validationResult } from 'express-validator';
|
import { isAlphanumeric } from 'ontime-utils';
|
||||||
|
|
||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { body, param, validationResult } from 'express-validator';
|
||||||
|
|
||||||
export const projectSanitiser = [
|
export const projectSanitiser = [
|
||||||
body('title').optional().isString().trim(),
|
body('title').optional().isString().trim(),
|
||||||
@@ -18,8 +20,15 @@ export const projectSanitiser = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const validateCustomField = [
|
export const validateCustomField = [
|
||||||
body('label').isString().trim(),
|
body('label')
|
||||||
body('type').isString().trim(),
|
.exists()
|
||||||
|
.isString()
|
||||||
|
.trim()
|
||||||
|
.custom((value) => {
|
||||||
|
return isAlphanumeric(value);
|
||||||
|
}),
|
||||||
|
body('type').exists().isString().trim(),
|
||||||
|
body('colour').exists().isString().trim(),
|
||||||
|
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@@ -29,9 +38,10 @@ export const validateCustomField = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const validateEditCustomField = [
|
export const validateEditCustomField = [
|
||||||
body('label').isString().trim(),
|
param('label').exists().isString().trim(),
|
||||||
body('field.label').optional().isString().trim(),
|
body('label').exists().isString().trim(),
|
||||||
body('field.type').optional().isString().trim(),
|
body('type').exists().isString().trim(),
|
||||||
|
body('colour').exists().isString().trim(),
|
||||||
|
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@@ -40,9 +50,8 @@ export const validateEditCustomField = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const validateDeleteCustomField = [
|
||||||
export const valdiateDeleteCustomField = [
|
param('label').exists().isString(),
|
||||||
body('label').isString(),
|
|
||||||
|
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { defaultExcelImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { body, param, validationResult } from 'express-validator';
|
||||||
|
import { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
|
export const validateRequestConnection = [
|
||||||
|
param('sheetId')
|
||||||
|
.exists()
|
||||||
|
.isString()
|
||||||
|
.isLength({
|
||||||
|
min: 40,
|
||||||
|
max: 100,
|
||||||
|
})
|
||||||
|
.withMessage('Sheet ID is usually 44 characters long'),
|
||||||
|
|
||||||
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const validateSheetOptions = [
|
||||||
|
param('sheetId').exists().isString(),
|
||||||
|
body('options')
|
||||||
|
.exists()
|
||||||
|
.isObject()
|
||||||
|
.custom((content) => {
|
||||||
|
// Check if the fileContent has the same keys as defaultExcelImportMap
|
||||||
|
const hasValidKeys = Object.keys(defaultExcelImportMap).every((key) => key in content);
|
||||||
|
|
||||||
|
// Check if all values in fileContent are strings
|
||||||
|
const hasValidValues = Object.values(content).every((value) => typeof value === 'string');
|
||||||
|
|
||||||
|
if (!hasValidKeys || !hasValidValues) {
|
||||||
|
throw new Error('Invalid file format');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
|
||||||
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* This module encapsulates logic related to
|
||||||
|
* Google Sheets
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
|
||||||
|
import { deleteFile } from '../utils/parserUtils.js';
|
||||||
|
import {
|
||||||
|
revoke,
|
||||||
|
handleClientSecret,
|
||||||
|
handleInitialConnection,
|
||||||
|
hasAuth,
|
||||||
|
download,
|
||||||
|
upload,
|
||||||
|
} from '../services/sheet-service/SheetService.js';
|
||||||
|
|
||||||
|
export async function requestConnection(req: Request, res: Response) {
|
||||||
|
const { sheetId } = req.params;
|
||||||
|
const file = req.file.path;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = readFileSync(file, 'utf-8');
|
||||||
|
const clientSecret = handleClientSecret(client);
|
||||||
|
const { verification_url, user_code } = await handleInitialConnection(clientSecret, sheetId);
|
||||||
|
|
||||||
|
res.status(200).send({ verification_url, user_code });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ message: String(error) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete uploaded file after parsing
|
||||||
|
try {
|
||||||
|
deleteFile(file);
|
||||||
|
} catch (_error) {
|
||||||
|
/** we dont handle failure here */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyAuthentication(_req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const authenticated = hasAuth();
|
||||||
|
res.status(200).send(authenticated);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ message: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeAuthentication(_req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const authenticated = revoke();
|
||||||
|
res.status(200).send(authenticated);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ message: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readFromSheet(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { sheetId } = req.params;
|
||||||
|
const { options } = req.body;
|
||||||
|
const data = await download(sheetId, options);
|
||||||
|
res.status(200).send(data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ message: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeToSheet(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { sheetId } = req.params;
|
||||||
|
const { options } = req.body;
|
||||||
|
await upload(sheetId, options);
|
||||||
|
res.status(200).send();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ message: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ export const event: Omit<OntimeEvent, 'id' | 'delay' | 'cue'> = {
|
|||||||
revision: 0,
|
revision: 0,
|
||||||
timeWarning: 120000,
|
timeWarning: 120000,
|
||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
|
custom: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const delay: Omit<OntimeDelay, 'id'> = {
|
export const delay: Omit<OntimeDelay, 'id'> = {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { uploadFile } from '../utils/upload.js';
|
import { uploadClientSecret, uploadFile } from '../utils/upload.js';
|
||||||
import {
|
import {
|
||||||
dbDownload,
|
dbDownload,
|
||||||
dbUpload,
|
dbUpload,
|
||||||
@@ -25,14 +25,6 @@ import {
|
|||||||
renameProjectFile,
|
renameProjectFile,
|
||||||
createProjectFile,
|
createProjectFile,
|
||||||
deleteProjectFile,
|
deleteProjectFile,
|
||||||
getAuthenticationUrl,
|
|
||||||
uploadSheetClientFile as uploadClientSecret,
|
|
||||||
pullSheet,
|
|
||||||
pushSheet,
|
|
||||||
postId,
|
|
||||||
getAuthentication,
|
|
||||||
getClientSecret as getClientSecret,
|
|
||||||
postWorksheet,
|
|
||||||
} from '../controllers/ontimeController.js';
|
} from '../controllers/ontimeController.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -46,12 +38,17 @@ import {
|
|||||||
validateProjectDuplicate,
|
validateProjectDuplicate,
|
||||||
validateLoadProjectFile,
|
validateLoadProjectFile,
|
||||||
validateProjectRename,
|
validateProjectRename,
|
||||||
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';
|
||||||
import { sanitizeProjectFilename } from '../utils/sanitizeProjectFilename.js';
|
import { sanitizeProjectFilename } from '../utils/sanitizeProjectFilename.js';
|
||||||
|
import {
|
||||||
|
revokeAuthentication,
|
||||||
|
readFromSheet,
|
||||||
|
requestConnection,
|
||||||
|
verifyAuthentication,
|
||||||
|
writeToSheet,
|
||||||
|
} from '../controllers/sheetsController.js';
|
||||||
|
import { validateRequestConnection, validateSheetOptions } from '../controllers/sheetController.validate.js';
|
||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
@@ -127,23 +124,13 @@ router.post('/project', projectSanitiser, createProjectFile);
|
|||||||
// create route between controller and '/ontime/project/:filename' endpoint
|
// create route between controller and '/ontime/project/:filename' endpoint
|
||||||
router.delete('/project/:filename', sanitizeProjectFilename, deleteProjectFile);
|
router.delete('/project/:filename', sanitizeProjectFilename, deleteProjectFile);
|
||||||
|
|
||||||
// TODO: move the google sheet stuff into a separate file
|
// create route between controller and '/sheet/:sheetId/connect' endpoint
|
||||||
// Google Sheet integration - Step 1
|
router.post('/sheet/:sheetId/connect', uploadClientSecret, validateRequestConnection, requestConnection);
|
||||||
router.post('/sheet/clientsecret', uploadFile, uploadClientSecret);
|
|
||||||
router.get('/sheet/clientsecret', uploadFile, getClientSecret);
|
|
||||||
|
|
||||||
// Google Sheet integration - Step 2
|
router.get('/sheet/connect', verifyAuthentication);
|
||||||
router.get('/sheet/authentication/url', getAuthenticationUrl);
|
|
||||||
router.get('/sheet/authentication', getAuthentication);
|
|
||||||
|
|
||||||
// Google Sheet integration - Step 3
|
router.post('/sheet/revoke', revokeAuthentication);
|
||||||
router.post('/sheet/sheetId', validateSheetId, postId);
|
|
||||||
|
|
||||||
// Google Sheet integration - Step 4
|
router.post('/sheet/:sheetId/read', validateSheetOptions, readFromSheet);
|
||||||
router.post('/sheet/worksheet', validateWorksheet, postWorksheet);
|
|
||||||
|
|
||||||
// Google Sheet integration - Step 5
|
router.post('/sheet/:sheetId/write', validateSheetOptions, writeToSheet);
|
||||||
router.post('/sheet-pull', validateSheetOptions, pullSheet);
|
|
||||||
|
|
||||||
// Google Sheet integration - Step 6
|
|
||||||
router.post('/sheet-push', validateSheetOptions, pushSheet);
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deleteCustomField,
|
deleteCustomField,
|
||||||
getCustomFields,
|
getCustomFields,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
projectSanitiser,
|
projectSanitiser,
|
||||||
validateCustomField,
|
validateCustomField,
|
||||||
|
validateDeleteCustomField,
|
||||||
validateEditCustomField,
|
validateEditCustomField,
|
||||||
} from '../controllers/projectController.validate.js';
|
} from '../controllers/projectController.validate.js';
|
||||||
|
|
||||||
@@ -25,6 +27,6 @@ router.get('/custom-field', getCustomFields);
|
|||||||
|
|
||||||
router.post('/custom-field', validateCustomField, postCustomField);
|
router.post('/custom-field', validateCustomField, postCustomField);
|
||||||
|
|
||||||
router.put('/custom-field', validateEditCustomField, putCustomField);
|
router.put('/custom-field/:label', validateEditCustomField, putCustomField);
|
||||||
|
|
||||||
router.delete('/custom-field/:label', deleteCustomField);
|
router.delete('/custom-field/:label', validateDeleteCustomField, deleteCustomField);
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export class OscIntegration implements IIntegration<OscSubscription> {
|
|||||||
this.enabledOut = enabledOut;
|
this.enabledOut = enabledOut;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info(LogOrigin.Tx, 'Initialising OSC integration...');
|
|
||||||
this.oscClient = new Client(targetIP, portOut);
|
this.oscClient = new Client(targetIP, portOut);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.oscClient = null;
|
this.oscClient = null;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
CustomFields,
|
||||||
LogOrigin,
|
LogOrigin,
|
||||||
OntimeBlock,
|
OntimeBlock,
|
||||||
OntimeDelay,
|
OntimeDelay,
|
||||||
@@ -195,7 +196,16 @@ export function notifyChanges(options: { timer?: boolean | string[]; external?:
|
|||||||
* Overrides the rundown with the given
|
* Overrides the rundown with the given
|
||||||
* @param rundown
|
* @param rundown
|
||||||
*/
|
*/
|
||||||
export async function setRundown(rundown: OntimeRundown) {
|
export async function initRundown(rundown: OntimeRundown, customFields: CustomFields) {
|
||||||
await cache.init(rundown);
|
await cache.init(rundown, customFields);
|
||||||
|
notifyChanges({ timer: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides the rundown with the given
|
||||||
|
* @param rundown
|
||||||
|
*/
|
||||||
|
export async function setRundown(rundown: OntimeRundown) {
|
||||||
|
await cache.setRundown(rundown);
|
||||||
notifyChanges({ timer: true });
|
notifyChanges({ timer: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
|
CustomFields,
|
||||||
EndAction,
|
EndAction,
|
||||||
|
EventCustomFields,
|
||||||
OntimeBlock,
|
OntimeBlock,
|
||||||
OntimeDelay,
|
OntimeDelay,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
@@ -10,7 +12,18 @@ import {
|
|||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
|
|
||||||
import { calculateRuntimeDelays, getDelayAt, calculateRuntimeDelaysFrom } from '../delayUtils.js';
|
import { calculateRuntimeDelays, getDelayAt, calculateRuntimeDelaysFrom } from '../delayUtils.js';
|
||||||
import { add, batchEdit, edit, generate, remove, reorder, swap } from '../rundownCache.js';
|
import {
|
||||||
|
add,
|
||||||
|
batchEdit,
|
||||||
|
edit,
|
||||||
|
generate,
|
||||||
|
remove,
|
||||||
|
reorder,
|
||||||
|
swap,
|
||||||
|
createCustomField,
|
||||||
|
editCustomField,
|
||||||
|
removeCustomField,
|
||||||
|
} from '../rundownCache.js';
|
||||||
|
|
||||||
describe('init() function', () => {
|
describe('init() function', () => {
|
||||||
it('creates normalised versions of a given rundown', () => {
|
it('creates normalised versions of a given rundown', () => {
|
||||||
@@ -206,6 +219,51 @@ describe('init() function', () => {
|
|||||||
expect((initResult.rundown['1'] as OntimeEvent).timeStart).toBe(1);
|
expect((initResult.rundown['1'] as OntimeEvent).timeStart).toBe(1);
|
||||||
expect(Object.keys(initResult.links).length).toBe(0);
|
expect(Object.keys(initResult.links).length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('custom properties feature', () => {
|
||||||
|
it('creates a map of custom properties', () => {
|
||||||
|
const customProperties: CustomFields = {
|
||||||
|
lighting: {
|
||||||
|
label: 'lighting',
|
||||||
|
type: 'string',
|
||||||
|
colour: 'red',
|
||||||
|
},
|
||||||
|
sound: {
|
||||||
|
label: 'sound',
|
||||||
|
type: 'string',
|
||||||
|
colour: 'red',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const testRundown: OntimeRundown = [
|
||||||
|
{
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
id: '1',
|
||||||
|
custom: {
|
||||||
|
lighting: { value: 'event 1 lx' },
|
||||||
|
} as EventCustomFields,
|
||||||
|
} as OntimeEvent,
|
||||||
|
{
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
id: '2',
|
||||||
|
custom: {
|
||||||
|
lighting: { value: 'event 2 lx' },
|
||||||
|
sound: { value: 'event 2 sound' },
|
||||||
|
} as EventCustomFields,
|
||||||
|
} as OntimeEvent,
|
||||||
|
];
|
||||||
|
const initResult = generate(testRundown, customProperties);
|
||||||
|
expect(initResult.order.length).toBe(2);
|
||||||
|
expect(initResult.assignedCustomProperties).toMatchObject({
|
||||||
|
lighting: ['1', '2'],
|
||||||
|
sound: ['2'],
|
||||||
|
});
|
||||||
|
expect((initResult.rundown['1'] as OntimeEvent).custom).toMatchObject({ lighting: { value: 'event 1 lx' } });
|
||||||
|
expect((initResult.rundown['2'] as OntimeEvent).custom).toMatchObject({
|
||||||
|
lighting: { value: 'event 2 lx' },
|
||||||
|
sound: { value: 'event 2 sound' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('add() mutation', () => {
|
describe('add() mutation', () => {
|
||||||
@@ -366,6 +424,7 @@ describe('calculateRuntimeDelays', () => {
|
|||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '659e1',
|
id: '659e1',
|
||||||
cue: '1',
|
cue: '1',
|
||||||
|
custom: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
duration: 600000,
|
duration: 600000,
|
||||||
@@ -403,6 +462,7 @@ describe('calculateRuntimeDelays', () => {
|
|||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '1c48f',
|
id: '1c48f',
|
||||||
cue: '2',
|
cue: '2',
|
||||||
|
custom: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
duration: 1200000,
|
duration: 1200000,
|
||||||
@@ -440,6 +500,7 @@ describe('calculateRuntimeDelays', () => {
|
|||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: 'd48c2',
|
id: 'd48c2',
|
||||||
cue: '3',
|
cue: '3',
|
||||||
|
custom: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
@@ -477,6 +538,7 @@ describe('calculateRuntimeDelays', () => {
|
|||||||
timeDanger: 60000,
|
timeDanger: 60000,
|
||||||
id: '2f185',
|
id: '2f185',
|
||||||
cue: '4',
|
cue: '4',
|
||||||
|
custom: {},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -524,6 +586,7 @@ describe('getDelayAt()', () => {
|
|||||||
id: '659e1',
|
id: '659e1',
|
||||||
delay: 0,
|
delay: 0,
|
||||||
cue: '1',
|
cue: '1',
|
||||||
|
custom: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
duration: 600000,
|
duration: 600000,
|
||||||
@@ -562,6 +625,7 @@ describe('getDelayAt()', () => {
|
|||||||
id: '1c48f',
|
id: '1c48f',
|
||||||
delay: 600000,
|
delay: 600000,
|
||||||
cue: '2',
|
cue: '2',
|
||||||
|
custom: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
duration: 1200000,
|
duration: 1200000,
|
||||||
@@ -600,6 +664,7 @@ describe('getDelayAt()', () => {
|
|||||||
id: 'd48c2',
|
id: 'd48c2',
|
||||||
delay: 1800000,
|
delay: 1800000,
|
||||||
cue: '3',
|
cue: '3',
|
||||||
|
custom: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
@@ -638,6 +703,7 @@ describe('getDelayAt()', () => {
|
|||||||
id: '2f185',
|
id: '2f185',
|
||||||
delay: 0,
|
delay: 0,
|
||||||
cue: '4',
|
cue: '4',
|
||||||
|
custom: {},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -702,6 +768,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
|||||||
id: '659e1',
|
id: '659e1',
|
||||||
delay: 0,
|
delay: 0,
|
||||||
cue: '1',
|
cue: '1',
|
||||||
|
custom: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
duration: 600000,
|
duration: 600000,
|
||||||
@@ -740,6 +807,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
|||||||
id: '1c48f',
|
id: '1c48f',
|
||||||
delay: 0,
|
delay: 0,
|
||||||
cue: '2',
|
cue: '2',
|
||||||
|
custom: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
duration: 1200000,
|
duration: 1200000,
|
||||||
@@ -778,6 +846,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
|||||||
id: 'd48c2',
|
id: 'd48c2',
|
||||||
delay: 1800000,
|
delay: 1800000,
|
||||||
cue: '3',
|
cue: '3',
|
||||||
|
custom: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
@@ -816,6 +885,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
|||||||
id: '2f185',
|
id: '2f185',
|
||||||
delay: 0,
|
delay: 0,
|
||||||
cue: '4',
|
cue: '4',
|
||||||
|
custom: {},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -827,3 +897,76 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
|||||||
expect((updatedRundown[4] as OntimeEvent).delay).toBe(600000 + 1200000);
|
expect((updatedRundown[4] as OntimeEvent).delay).toBe(600000 + 1200000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('custom fields', () => {
|
||||||
|
describe('createCustomField()', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mock('../../classes/data-provider/DataProvider.js', () => {
|
||||||
|
return {
|
||||||
|
DataProvider: {
|
||||||
|
...vi.fn().mockImplementation(() => {
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
getCustomFields: vi.fn().mockReturnValue({}),
|
||||||
|
setCustomFields: vi.fn().mockImplementation((newData) => {
|
||||||
|
return newData;
|
||||||
|
}),
|
||||||
|
persist: vi.fn().mockReturnValue({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a field from given parameters', async () => {
|
||||||
|
const expected = {
|
||||||
|
lighting: {
|
||||||
|
label: 'lighting',
|
||||||
|
type: 'string',
|
||||||
|
colour: 'blue',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const customField = await createCustomField({ label: 'lighting', type: 'string', colour: 'blue' });
|
||||||
|
expect(customField).toStrictEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('editCustomField()', () => {
|
||||||
|
it('edits a field with a given label', async () => {
|
||||||
|
await createCustomField({ label: 'sound', type: 'string', colour: 'blue' });
|
||||||
|
|
||||||
|
const expected = {
|
||||||
|
lighting: {
|
||||||
|
label: 'lighting',
|
||||||
|
type: 'string',
|
||||||
|
colour: 'blue',
|
||||||
|
},
|
||||||
|
sound: {
|
||||||
|
label: 'sound',
|
||||||
|
type: 'string',
|
||||||
|
colour: 'blue',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const customField = await editCustomField('sound', { label: 'sound', type: 'string', colour: 'blue' });
|
||||||
|
|
||||||
|
expect(customField).toStrictEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeCustomField()', () => {
|
||||||
|
it('deletes a field with a given label', async () => {
|
||||||
|
const expected = {
|
||||||
|
lighting: {
|
||||||
|
label: 'lighting',
|
||||||
|
type: 'string',
|
||||||
|
colour: 'blue',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const customField = await removeCustomField('sound');
|
||||||
|
|
||||||
|
expect(customField).toStrictEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { isOntimeDelay, isOntimeEvent, OntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
|
||||||
import {
|
import {
|
||||||
generateId,
|
CustomField,
|
||||||
deleteAtIndex,
|
CustomFieldLabel,
|
||||||
insertAtIndex,
|
CustomFields,
|
||||||
reorderArray,
|
isOntimeDelay,
|
||||||
swapEventData,
|
isOntimeEvent,
|
||||||
getLinkedTimes,
|
OntimeEvent,
|
||||||
formatFromMillis,
|
OntimeRundown,
|
||||||
} from 'ontime-utils';
|
OntimeRundownEntry,
|
||||||
|
} from 'ontime-types';
|
||||||
|
import { generateId, deleteAtIndex, insertAtIndex, reorderArray, swapEventData, getLinkedTimes } from 'ontime-utils';
|
||||||
|
|
||||||
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
import { createPatch } from '../../utils/parser.js';
|
import { createPatch } from '../../utils/parser.js';
|
||||||
@@ -17,8 +18,11 @@ type EventID = string;
|
|||||||
type NormalisedRundown = Record<EventID, OntimeRundownEntry>;
|
type NormalisedRundown = Record<EventID, OntimeRundownEntry>;
|
||||||
|
|
||||||
let persistedRundown: OntimeRundown = [];
|
let persistedRundown: OntimeRundown = [];
|
||||||
/** Utility function gets rundown from DataProvider */
|
let persistedCustomFields: CustomFields = {};
|
||||||
|
|
||||||
|
/** Utility function gets to expose data */
|
||||||
export const getPersistedRundown = (): OntimeRundown => persistedRundown;
|
export const getPersistedRundown = (): OntimeRundown => persistedRundown;
|
||||||
|
export const getCustomFields = (): CustomFields => persistedCustomFields;
|
||||||
|
|
||||||
let rundown: NormalisedRundown = {};
|
let rundown: NormalisedRundown = {};
|
||||||
let order: EventID[] = [];
|
let order: EventID[] = [];
|
||||||
@@ -28,17 +32,38 @@ let totalDelay = 0;
|
|||||||
|
|
||||||
let links: Record<EventID, EventID> = {};
|
let links: Record<EventID, EventID> = {};
|
||||||
|
|
||||||
export async function init(initialRundown: OntimeRundown) {
|
/**
|
||||||
|
* Object that contains renamings to custom fields
|
||||||
|
* Used to rename the custom fields in the events
|
||||||
|
* @example
|
||||||
|
* {
|
||||||
|
* oldLabel: newLabel
|
||||||
|
* lighting: lx
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
const customFieldChangelog = {};
|
||||||
|
const assignedCustomFields: Record<CustomFieldLabel, EventID[]> = {};
|
||||||
|
|
||||||
|
export async function init(initialRundown: OntimeRundown, customFields: CustomFields) {
|
||||||
persistedRundown = structuredClone(initialRundown);
|
persistedRundown = structuredClone(initialRundown);
|
||||||
|
persistedCustomFields = structuredClone(customFields);
|
||||||
generate();
|
generate();
|
||||||
await DataProvider.setRundown(persistedRundown);
|
await DataProvider.setRundown(persistedRundown);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setRundown(initialRundown: OntimeRundown) {
|
||||||
|
persistedRundown = structuredClone(initialRundown);
|
||||||
|
generate();
|
||||||
|
await DataProvider.setRundown(persistedRundown);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Utility initialises cache
|
* Utility initialises cache
|
||||||
* @param rundown
|
* @param rundown
|
||||||
*/
|
*/
|
||||||
export function generate(initialRundown: OntimeRundown = persistedRundown) {
|
export function generate(
|
||||||
|
initialRundown: OntimeRundown = persistedRundown,
|
||||||
|
customProperties: CustomFields = persistedCustomFields,
|
||||||
|
) {
|
||||||
// we decided to re-write this dataset for every change
|
// we decided to re-write this dataset for every change
|
||||||
// instead of maintaining logic to update it
|
// instead of maintaining logic to update it
|
||||||
|
|
||||||
@@ -80,6 +105,21 @@ export function generate(initialRundown: OntimeRundown = persistedRundown) {
|
|||||||
// update the persisted event
|
// update the persisted event
|
||||||
initialRundown[i] = updatedEvent;
|
initialRundown[i] = updatedEvent;
|
||||||
}
|
}
|
||||||
|
if (updatedEvent.custom) {
|
||||||
|
for (const property in updatedEvent.custom) {
|
||||||
|
const isValid = property in customProperties;
|
||||||
|
if (!isValid) {
|
||||||
|
delete updatedEvent.custom[property];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(assignedCustomFields[property])) {
|
||||||
|
assignedCustomFields[property] = [];
|
||||||
|
}
|
||||||
|
assignedCustomFields[property].push(updatedEvent.id);
|
||||||
|
}
|
||||||
|
// update the persisted event
|
||||||
|
initialRundown[i] = updatedEvent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculate delays
|
// calculate delays
|
||||||
@@ -103,7 +143,7 @@ export function generate(initialRundown: OntimeRundown = persistedRundown) {
|
|||||||
|
|
||||||
isStale = false;
|
isStale = false;
|
||||||
totalDelay = accumulatedDelay;
|
totalDelay = accumulatedDelay;
|
||||||
return { rundown, order, links, totalDelay };
|
return { rundown, order, links, totalDelay, assignedCustomProperties: assignedCustomFields };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns an ID guaranteed to be unique */
|
/** Returns an ID guaranteed to be unique */
|
||||||
@@ -241,11 +281,9 @@ export function edit({ persistedRundown, eventId, patch }: EditArgs): Required<M
|
|||||||
throw new Error('Invalid event type');
|
throw new Error('Invalid event type');
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-expect-error -- testing
|
|
||||||
console.log('patch', formatFromMillis(patch?.timeStart ?? 0, 'HH:mm:ss'));
|
|
||||||
|
|
||||||
const eventInMemory = persistedRundown[indexAt];
|
const eventInMemory = persistedRundown[indexAt];
|
||||||
const newEvent = makeEvent(eventInMemory, patch);
|
const newEvent = makeEvent(eventInMemory, patch);
|
||||||
|
console.log('got', patch, 'will make', newEvent);
|
||||||
|
|
||||||
const newRundown = [...persistedRundown];
|
const newRundown = [...persistedRundown];
|
||||||
newRundown[indexAt] = newEvent;
|
newRundown[indexAt] = newEvent;
|
||||||
@@ -321,3 +359,73 @@ export function swap({ persistedRundown, fromId, toId }: SwapArgs): MutatingRetu
|
|||||||
|
|
||||||
return { newRundown };
|
return { newRundown };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitises and creates a custom field in the database
|
||||||
|
* @param field
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const createCustomField = async (field: CustomField) => {
|
||||||
|
const { label, type, colour } = field;
|
||||||
|
|
||||||
|
// check if label already exists
|
||||||
|
const alreadyExists = Object.hasOwn(persistedCustomFields, label);
|
||||||
|
|
||||||
|
if (alreadyExists) {
|
||||||
|
throw new Error('Label already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// update object and persist
|
||||||
|
persistedCustomFields[label] = { label, type, colour };
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
DataProvider.setCustomFields(persistedCustomFields);
|
||||||
|
});
|
||||||
|
|
||||||
|
return persistedCustomFields;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edits an existing custom field in the database
|
||||||
|
* @param label
|
||||||
|
* @param newField
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const editCustomField = async (label: string, newField: Partial<CustomField>) => {
|
||||||
|
if (!(label in persistedCustomFields)) {
|
||||||
|
throw new Error('Could not find label');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingField = persistedCustomFields[label];
|
||||||
|
if (existingField.type !== newField.type) {
|
||||||
|
throw new Error('Change of field type is not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingField.label !== newField.label) {
|
||||||
|
customFieldChangelog[label] = newField.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
persistedCustomFields[label] = { ...existingField, ...newField };
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
DataProvider.setCustomFields(persistedCustomFields);
|
||||||
|
});
|
||||||
|
|
||||||
|
return persistedCustomFields;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a custom field from the database
|
||||||
|
* @param label
|
||||||
|
*/
|
||||||
|
export const removeCustomField = async (label: string) => {
|
||||||
|
if (label in persistedCustomFields) {
|
||||||
|
delete persistedCustomFields[label];
|
||||||
|
}
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
DataProvider.setCustomFields(persistedCustomFields);
|
||||||
|
});
|
||||||
|
|
||||||
|
return persistedCustomFields;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
/**
|
||||||
|
* Service aggregates business logic related
|
||||||
|
* to integration with Google Sheets API
|
||||||
|
* @link https://developers.google.com/identity/protocols/oauth2/limited-input-device
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AuthenticationStatus, LogOrigin, MaybeString, OntimeRundown, UserFields } from 'ontime-types';
|
||||||
|
|
||||||
|
import { sheets, sheets_v4 } from '@googleapis/sheets';
|
||||||
|
import { Credentials, OAuth2Client } from 'google-auth-library';
|
||||||
|
import got from 'got';
|
||||||
|
|
||||||
|
import { resolveSheetsDirectory } from '../../setup.js';
|
||||||
|
import { ensureDirectory } from '../../utils/fileManagement.js';
|
||||||
|
import { type ClientSecret, cellRequestFromEvent, getA1Notation, validateClientSecret } from './sheetUtils.js';
|
||||||
|
import { ExcelImportMap } from 'ontime-utils';
|
||||||
|
import { parseExcel } from '../../utils/parser.js';
|
||||||
|
import { logger } from '../../classes/Logger.js';
|
||||||
|
import { parseRundown, parseUserFields } from '../../utils/parserFunctions.js';
|
||||||
|
import { getRundown } from '../rundown-service/rundownUtils.js';
|
||||||
|
|
||||||
|
const sheetScope = 'https://www.googleapis.com/auth/spreadsheets';
|
||||||
|
const codesUrl = 'https://oauth2.googleapis.com/device/code';
|
||||||
|
const tokenUrl = 'https://oauth2.googleapis.com/token';
|
||||||
|
const grantType = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||||
|
|
||||||
|
let currentAuthClient: OAuth2Client | null = null;
|
||||||
|
let currentClientSecret: ClientSecret | null = null;
|
||||||
|
let currentAuthUrl: MaybeString = null;
|
||||||
|
let currentAuthCode: MaybeString = null;
|
||||||
|
|
||||||
|
let currentSheetId: MaybeString = null;
|
||||||
|
|
||||||
|
let pollInterval: NodeJS.Timer | null = null;
|
||||||
|
let cleanupTimeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
currentAuthClient = null;
|
||||||
|
currentClientSecret = null;
|
||||||
|
currentAuthUrl = null;
|
||||||
|
currentAuthCode = null;
|
||||||
|
|
||||||
|
currentSheetId = null;
|
||||||
|
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
pollInterval = null;
|
||||||
|
}
|
||||||
|
if (cleanupTimeout) {
|
||||||
|
clearTimeout(cleanupTimeout);
|
||||||
|
cleanupTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise module
|
||||||
|
*/
|
||||||
|
export function init() {
|
||||||
|
reset();
|
||||||
|
ensureDirectory(resolveSheetsDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets all state related to an eventual connection
|
||||||
|
*/
|
||||||
|
export function revoke(): ReturnType<typeof hasAuth> {
|
||||||
|
reset();
|
||||||
|
return hasAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses and validates a client secret string
|
||||||
|
* @param clientSecret
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function handleClientSecret(clientSecret: string): ClientSecret {
|
||||||
|
const clientSecretObject = JSON.parse(clientSecret);
|
||||||
|
const isValid = validateClientSecret(clientSecretObject);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error('Client secret invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientSecretObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://developers.google.com/identity/protocols/oauth2/limited-input-device#success-response
|
||||||
|
type CodesResponse = {
|
||||||
|
device_code: string;
|
||||||
|
expires_in: number;
|
||||||
|
interval: number;
|
||||||
|
user_code: string;
|
||||||
|
verification_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Establishes connection with Google Auth server
|
||||||
|
* and retrieves device codes
|
||||||
|
* @param clientSecret
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async function getDeviceCodes(clientSecret: ClientSecret): Promise<CodesResponse> {
|
||||||
|
const deviceCodes: CodesResponse = await got
|
||||||
|
.post(codesUrl, {
|
||||||
|
json: {
|
||||||
|
client_id: clientSecret.installed.client_id,
|
||||||
|
scope: sheetScope,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.json();
|
||||||
|
|
||||||
|
return deviceCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets credentials from Google Auth server
|
||||||
|
* @param clientSecret
|
||||||
|
* @param device_code
|
||||||
|
* @param interval
|
||||||
|
* @param expires_in
|
||||||
|
* @param postAction
|
||||||
|
*/
|
||||||
|
function verifyConnection(
|
||||||
|
clientSecret: ClientSecret,
|
||||||
|
device_code: string,
|
||||||
|
interval: number,
|
||||||
|
expires_in: number,
|
||||||
|
postAction: () => void,
|
||||||
|
) {
|
||||||
|
// create poller to check for auth
|
||||||
|
pollInterval = setInterval(pollForAuth, interval * 1000);
|
||||||
|
|
||||||
|
// schedule to clear the poller when we know the token is no longer valid
|
||||||
|
if (cleanupTimeout) {
|
||||||
|
clearTimeout(cleanupTimeout);
|
||||||
|
cleanupTimeout = null;
|
||||||
|
}
|
||||||
|
cleanupTimeout = setTimeout(() => {
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
pollInterval = null;
|
||||||
|
}
|
||||||
|
}, expires_in * 1000);
|
||||||
|
|
||||||
|
async function pollForAuth() {
|
||||||
|
// server returns 428 if user hasnt yet completed the auth process
|
||||||
|
try {
|
||||||
|
logger.info(LogOrigin.Server, 'Polling for auth...');
|
||||||
|
const auth: Credentials = await got
|
||||||
|
.post(tokenUrl, {
|
||||||
|
json: {
|
||||||
|
client_id: clientSecret.installed.client_id,
|
||||||
|
client_secret: clientSecret.installed.client_secret,
|
||||||
|
device_code,
|
||||||
|
grant_type: grantType,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.json();
|
||||||
|
|
||||||
|
logger.info(LogOrigin.Server, 'Successfully Authenticated');
|
||||||
|
const client = new OAuth2Client({
|
||||||
|
clientId: clientSecret.installed.client_id,
|
||||||
|
clientSecret: clientSecret.installed.client_secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
client.setCredentials({
|
||||||
|
refresh_token: auth.refresh_token,
|
||||||
|
access_token: auth.access_token,
|
||||||
|
scope: auth.scope,
|
||||||
|
token_type: auth.token_type,
|
||||||
|
});
|
||||||
|
|
||||||
|
// save client and cancel tasks
|
||||||
|
currentAuthClient = client;
|
||||||
|
|
||||||
|
if (cleanupTimeout) {
|
||||||
|
clearTimeout(cleanupTimeout);
|
||||||
|
cleanupTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
pollInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
postAction();
|
||||||
|
} catch (_error) {
|
||||||
|
/** we do not handle failure */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAuth(): { authenticated: AuthenticationStatus } {
|
||||||
|
if (cleanupTimeout) {
|
||||||
|
return { authenticated: 'pending' };
|
||||||
|
}
|
||||||
|
return { authenticated: currentAuthClient ? 'authenticated' : 'not_authenticated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifySheet(
|
||||||
|
sheetId = currentSheetId,
|
||||||
|
authClient = currentAuthClient,
|
||||||
|
): Promise<{ worksheetOptions: string[] }> {
|
||||||
|
const spreadsheets = await sheets({ version: 'v4', auth: authClient }).spreadsheets.get({
|
||||||
|
spreadsheetId: sheetId,
|
||||||
|
includeGridData: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (spreadsheets.status !== 200) {
|
||||||
|
throw new Error(spreadsheets.statusText);
|
||||||
|
}
|
||||||
|
return { worksheetOptions: spreadsheets.data.sheets.map((i) => i.properties.title) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleInitialConnection(
|
||||||
|
clientSecret: ClientSecret,
|
||||||
|
sheetId: string,
|
||||||
|
): Promise<{ verification_url: string; user_code: string }> {
|
||||||
|
// TODO: check if the clientSecret has changed
|
||||||
|
currentClientSecret = clientSecret;
|
||||||
|
|
||||||
|
// we know there is an ongoing process if there is a timeout for cleanup
|
||||||
|
// if there is an ongoing process, we return its data
|
||||||
|
if (cleanupTimeout) {
|
||||||
|
return { verification_url: currentAuthUrl, user_code: currentAuthCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { device_code, expires_in, interval, user_code, verification_url } = await getDeviceCodes(currentClientSecret);
|
||||||
|
currentAuthUrl = verification_url;
|
||||||
|
currentAuthCode = user_code;
|
||||||
|
currentSheetId = sheetId;
|
||||||
|
|
||||||
|
// schedule verifying token and the existence of the sheetID
|
||||||
|
verifyConnection(currentClientSecret, device_code, interval, expires_in, verifySheet);
|
||||||
|
|
||||||
|
return { verification_url, user_code };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow calling verification for sheetId
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export async function getWorksheetOptions(sheetId: string): ReturnType<typeof verifySheet> {
|
||||||
|
if (!currentAuthClient) {
|
||||||
|
throw new Error('Not authenticated');
|
||||||
|
}
|
||||||
|
currentSheetId = sheetId;
|
||||||
|
|
||||||
|
return verifySheet(sheetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyWorksheet(sheetId: string, worksheet: string): Promise<{ worksheetId: number; range: string }> {
|
||||||
|
const spreadsheets = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.get({
|
||||||
|
spreadsheetId: sheetId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (spreadsheets.status !== 200) {
|
||||||
|
throw new Error(`Request failed: ${spreadsheets.status} ${spreadsheets.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedWorksheet = spreadsheets.data.sheets.find((n) => n.properties.title == worksheet);
|
||||||
|
|
||||||
|
if (!selectedWorksheet) {
|
||||||
|
throw new Error('Could not find worksheet');
|
||||||
|
}
|
||||||
|
|
||||||
|
const endCell = getA1Notation(
|
||||||
|
selectedWorksheet.properties.gridProperties.rowCount,
|
||||||
|
selectedWorksheet.properties.gridProperties.columnCount,
|
||||||
|
);
|
||||||
|
return { worksheetId: selectedWorksheet.properties.sheetId, range: `${worksheet}!A1:${endCell}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upload(sheetId: string, options: ExcelImportMap) {
|
||||||
|
const { worksheetId, range } = await verifyWorksheet(sheetId, options.worksheet);
|
||||||
|
|
||||||
|
const readResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.values.get({
|
||||||
|
spreadsheetId: sheetId,
|
||||||
|
valueRenderOption: 'FORMATTED_VALUE',
|
||||||
|
majorDimension: 'ROWS',
|
||||||
|
range,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (readResponse.status !== 200) {
|
||||||
|
throw new Error(`Sheet read failed: ${readResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rundownMetadata } = parseExcel(readResponse.data.values, options);
|
||||||
|
const rundown = getRundown();
|
||||||
|
const titleRow = Object.values(rundownMetadata)[0]['row'];
|
||||||
|
const updateRundown = Array<sheets_v4.Schema$Request>();
|
||||||
|
|
||||||
|
// we can't delete the last unfrozen 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 length 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)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({
|
||||||
|
spreadsheetId: sheetId,
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function download(
|
||||||
|
sheetId: string,
|
||||||
|
options: ExcelImportMap,
|
||||||
|
): Promise<{
|
||||||
|
rundown: OntimeRundown;
|
||||||
|
userFields: UserFields;
|
||||||
|
}> {
|
||||||
|
const { range } = await verifyWorksheet(sheetId, options.worksheet);
|
||||||
|
|
||||||
|
const googleResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.values.get({
|
||||||
|
spreadsheetId: sheetId,
|
||||||
|
valueRenderOption: 'FORMATTED_VALUE',
|
||||||
|
majorDimension: 'ROWS',
|
||||||
|
range,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (googleResponse.status !== 200) {
|
||||||
|
throw new Error(`Sheet read failed: ${googleResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataFromSheet = parseExcel(googleResponse.data.values, options);
|
||||||
|
const rundown = parseRundown(dataFromSheet);
|
||||||
|
if (rundown.length < 1) {
|
||||||
|
throw new Error('Sheet: Could not find data to import in the worksheet');
|
||||||
|
}
|
||||||
|
const userFields = parseUserFields(dataFromSheet);
|
||||||
|
return { rundown, userFields };
|
||||||
|
}
|
||||||
+13
-7
@@ -1,4 +1,4 @@
|
|||||||
import { EndAction, OntimeRundownEntry, SupportedEvent, TimeStrategy, TimerType } from 'ontime-types';
|
import { EndAction, OntimeEvent, SupportedEvent, TimeStrategy, TimerType } from 'ontime-types';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import { getA1Notation, cellRequestFromEvent } from '../sheetUtils.js';
|
import { getA1Notation, cellRequestFromEvent } from '../sheetUtils.js';
|
||||||
@@ -20,7 +20,7 @@ describe('getA1Notation()', () => {
|
|||||||
|
|
||||||
describe('cellRequestFromEvent()', () => {
|
describe('cellRequestFromEvent()', () => {
|
||||||
test('string to string', () => {
|
test('string to string', () => {
|
||||||
const event: OntimeRundownEntry = {
|
const event: OntimeEvent = {
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
cue: '1',
|
cue: '1',
|
||||||
title: 'Fancy',
|
title: 'Fancy',
|
||||||
@@ -51,6 +51,7 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
id: '1358',
|
id: '1358',
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
timeDanger: 0,
|
timeDanger: 0,
|
||||||
|
custom: {},
|
||||||
};
|
};
|
||||||
const metadata = {
|
const metadata = {
|
||||||
type: { row: 1, col: 14 },
|
type: { row: 1, col: 14 },
|
||||||
@@ -87,7 +88,7 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('numer to timer', () => {
|
test('numer to timer', () => {
|
||||||
const event: OntimeRundownEntry = {
|
const event: OntimeEvent = {
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
cue: '1',
|
cue: '1',
|
||||||
title: 'Fancy',
|
title: 'Fancy',
|
||||||
@@ -118,6 +119,7 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
id: '1358',
|
id: '1358',
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
timeDanger: 0,
|
timeDanger: 0,
|
||||||
|
custom: {},
|
||||||
};
|
};
|
||||||
const metadata = {
|
const metadata = {
|
||||||
type: { row: 1, col: 14 },
|
type: { row: 1, col: 14 },
|
||||||
@@ -155,7 +157,7 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('boolean to x', () => {
|
test('boolean to x', () => {
|
||||||
const event: OntimeRundownEntry = {
|
const event: OntimeEvent = {
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
cue: '1',
|
cue: '1',
|
||||||
title: 'Fancy',
|
title: 'Fancy',
|
||||||
@@ -186,6 +188,7 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
id: '1358',
|
id: '1358',
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
timeDanger: 0,
|
timeDanger: 0,
|
||||||
|
custom: {},
|
||||||
};
|
};
|
||||||
const metadata = {
|
const metadata = {
|
||||||
type: { row: 1, col: 14 },
|
type: { row: 1, col: 14 },
|
||||||
@@ -223,7 +226,7 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('spacing in metadata', () => {
|
test('spacing in metadata', () => {
|
||||||
const event: OntimeRundownEntry = {
|
const event: OntimeEvent = {
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
cue: '1',
|
cue: '1',
|
||||||
title: 'Fancy',
|
title: 'Fancy',
|
||||||
@@ -254,6 +257,7 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
id: '1358',
|
id: '1358',
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
timeDanger: 0,
|
timeDanger: 0,
|
||||||
|
custom: {},
|
||||||
};
|
};
|
||||||
const metadata = {
|
const metadata = {
|
||||||
cue: { row: 1, col: 0 },
|
cue: { row: 1, col: 0 },
|
||||||
@@ -268,7 +272,7 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('metadata offset from zero', () => {
|
test('metadata offset from zero', () => {
|
||||||
const event: OntimeRundownEntry = {
|
const event: OntimeEvent = {
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
cue: '1',
|
cue: '1',
|
||||||
title: 'Fancy',
|
title: 'Fancy',
|
||||||
@@ -299,6 +303,7 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
id: '1358',
|
id: '1358',
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
timeDanger: 0,
|
timeDanger: 0,
|
||||||
|
custom: {},
|
||||||
};
|
};
|
||||||
const metadata = {
|
const metadata = {
|
||||||
cue: { row: 1, col: 5 },
|
cue: { row: 1, col: 5 },
|
||||||
@@ -313,7 +318,7 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('sheet setup', () => {
|
test('sheet setup', () => {
|
||||||
const event: OntimeRundownEntry = {
|
const event: OntimeEvent = {
|
||||||
type: SupportedEvent.Event,
|
type: SupportedEvent.Event,
|
||||||
cue: '1',
|
cue: '1',
|
||||||
title: 'Fancy',
|
title: 'Fancy',
|
||||||
@@ -344,6 +349,7 @@ describe('cellRequestFromEvent()', () => {
|
|||||||
id: '1358',
|
id: '1358',
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
timeDanger: 0,
|
timeDanger: 0,
|
||||||
|
custom: {},
|
||||||
};
|
};
|
||||||
const metadata = {
|
const metadata = {
|
||||||
cue: { row: 10, col: 5 },
|
cue: { row: 10, col: 5 },
|
||||||
+34
-2
@@ -1,6 +1,36 @@
|
|||||||
import { sheets_v4 } from '@googleapis/sheets';
|
|
||||||
import { millisToString } from 'ontime-utils';
|
|
||||||
import { OntimeRundownEntry, isOntimeBlock, isOntimeEvent } from 'ontime-types';
|
import { OntimeRundownEntry, isOntimeBlock, isOntimeEvent } from 'ontime-types';
|
||||||
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { sheets_v4 } from '@googleapis/sheets';
|
||||||
|
|
||||||
|
// we expect client secret file to contain the following keys
|
||||||
|
const requiredClientKeys = [
|
||||||
|
'client_id',
|
||||||
|
'auth_uri',
|
||||||
|
'token_uri',
|
||||||
|
'token_uri',
|
||||||
|
'auth_provider_x509_cert_url',
|
||||||
|
'client_secret',
|
||||||
|
];
|
||||||
|
|
||||||
|
export type ClientSecret = {
|
||||||
|
installed: {
|
||||||
|
client_id: string;
|
||||||
|
auth_uri: string;
|
||||||
|
token_uri: string;
|
||||||
|
auth_provider_x509_cert_url: string;
|
||||||
|
client_secret: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard validates a given client secrets file
|
||||||
|
* @param clientSecret
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function validateClientSecret(clientSecret: object): clientSecret is ClientSecret {
|
||||||
|
return requiredClientKeys.every((key) => Object.keys(clientSecret['installed']).includes(key));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@@ -21,10 +51,12 @@ export function getA1Notation(row: number, column: number): string {
|
|||||||
const a1Notation = [`${row + 1}`];
|
const a1Notation = [`${row + 1}`];
|
||||||
const totalAlphabets = 'Z'.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
|
const totalAlphabets = 'Z'.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
|
||||||
let block = column;
|
let block = column;
|
||||||
|
|
||||||
while (block >= 0) {
|
while (block >= 0) {
|
||||||
a1Notation.unshift(String.fromCharCode((block % totalAlphabets) + 'A'.charCodeAt(0)));
|
a1Notation.unshift(String.fromCharCode((block % totalAlphabets) + 'A'.charCodeAt(0)));
|
||||||
block = Math.floor(block / totalAlphabets) - 1;
|
block = Math.floor(block / totalAlphabets) - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return a1Notation.join('');
|
return a1Notation.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,5 +124,8 @@ export const pathToStartDemo = config.demo.filename.map((file) => {
|
|||||||
// path to restore file
|
// path to restore file
|
||||||
export const resolveRestoreFile = join(getAppDataPath(), config.restoreFile);
|
export const resolveRestoreFile = join(getAppDataPath(), config.restoreFile);
|
||||||
|
|
||||||
|
// path to sheets folder
|
||||||
|
export const resolveSheetsDirectory = join(getAppDataPath(), config.sheets.directory);
|
||||||
|
|
||||||
// path to crash reports
|
// path to crash reports
|
||||||
export const resolveCrashReportDirectory = getAppDataPath();
|
export const resolveCrashReportDirectory = getAppDataPath();
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import { createCustomField, editCustomField, removeCustomField } from '../customFields.js';
|
|
||||||
|
|
||||||
describe('createCustomField()', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mock('../../classes/data-provider/DataProvider.js', () => {
|
|
||||||
return {
|
|
||||||
DataProvider: {
|
|
||||||
...vi.fn().mockImplementation(() => {
|
|
||||||
return {};
|
|
||||||
}),
|
|
||||||
getCustomFields: vi.fn().mockReturnValue({}),
|
|
||||||
setCustomFields: vi.fn().mockImplementation((newData) => {
|
|
||||||
return newData;
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates a field from given parameters', async () => {
|
|
||||||
const expected = {
|
|
||||||
lighting: {
|
|
||||||
label: 'lighting',
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const customField = await createCustomField({ label: 'lighting', type: 'text' });
|
|
||||||
expect(customField).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('editCustomField()', () => {
|
|
||||||
it('edits a field with a given label', async () => {
|
|
||||||
await createCustomField({ label: 'sound', type: 'text' });
|
|
||||||
|
|
||||||
const expected = {
|
|
||||||
lighting: {
|
|
||||||
label: 'lighting',
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
sound: {
|
|
||||||
label: 'sound',
|
|
||||||
type: 'number',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const customField = await editCustomField('sound', { label: 'sound', type: 'number' });
|
|
||||||
|
|
||||||
expect(customField).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('removeCustomField()', () => {
|
|
||||||
it('deletes a field with a given label', async () => {
|
|
||||||
const expected = {
|
|
||||||
lighting: {
|
|
||||||
label: 'lighting',
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const customField = await removeCustomField('sound');
|
|
||||||
|
|
||||||
expect(customField).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -52,6 +52,7 @@ describe('test json parser with valid def', () => {
|
|||||||
revision: 0,
|
revision: 0,
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
timeDanger: 0,
|
timeDanger: 0,
|
||||||
|
custom: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'f24d',
|
id: 'f24d',
|
||||||
@@ -84,6 +85,7 @@ describe('test json parser with valid def', () => {
|
|||||||
revision: 0,
|
revision: 0,
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
timeDanger: 0,
|
timeDanger: 0,
|
||||||
|
custom: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bbc5',
|
id: 'bbc5',
|
||||||
@@ -116,6 +118,7 @@ describe('test json parser with valid def', () => {
|
|||||||
revision: 0,
|
revision: 0,
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
timeDanger: 0,
|
timeDanger: 0,
|
||||||
|
custom: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// testing incomplete dataset
|
// testing incomplete dataset
|
||||||
@@ -169,6 +172,7 @@ describe('test json parser with valid def', () => {
|
|||||||
revision: 0,
|
revision: 0,
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
timeDanger: 0,
|
timeDanger: 0,
|
||||||
|
custom: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '08e9',
|
id: '08e9',
|
||||||
@@ -201,6 +205,7 @@ describe('test json parser with valid def', () => {
|
|||||||
revision: 0,
|
revision: 0,
|
||||||
timeWarning: 0,
|
timeWarning: 0,
|
||||||
timeDanger: 0,
|
timeDanger: 0,
|
||||||
|
custom: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// testing incomplete dataset
|
// testing incomplete dataset
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import { isAlphanumeric } from 'ontime-utils';
|
|
||||||
import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
|
||||||
import { CustomField } from 'ontime-types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitises and creates a custom field in the database
|
|
||||||
* @param field
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export const createCustomField = async (field: CustomField) => {
|
|
||||||
if (!isAlphanumeric(field.label)) {
|
|
||||||
throw new Error('Label must be Alphanumeric');
|
|
||||||
}
|
|
||||||
|
|
||||||
const customFields = DataProvider.getCustomFields();
|
|
||||||
if (Object.keys(customFields).find((f) => f === field.label) !== undefined) {
|
|
||||||
throw new Error('Label already exists');
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(customFields, { [field.label]: field });
|
|
||||||
const newCustomFields = await DataProvider.setCustomFields(customFields);
|
|
||||||
|
|
||||||
return newCustomFields;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Edits an existing custom field in the database
|
|
||||||
* @param label
|
|
||||||
* @param field
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export const editCustomField = async (label: string, field: Partial<CustomField>) => {
|
|
||||||
const existingFields = DataProvider.getCustomFields();
|
|
||||||
if (!(label in existingFields)) {
|
|
||||||
throw new Error('Could not find label');
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingField = existingFields[label];
|
|
||||||
if (!existingField) {
|
|
||||||
throw new Error('Could not find label');
|
|
||||||
}
|
|
||||||
|
|
||||||
existingFields[label] = { ...existingField, ...field };
|
|
||||||
|
|
||||||
const newCustomFields = await DataProvider.setCustomFields(existingFields);
|
|
||||||
return newCustomFields;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a custom field from the database
|
|
||||||
* @param label
|
|
||||||
*/
|
|
||||||
export const removeCustomField = async (label: string) => {
|
|
||||||
const existingFields = DataProvider.getCustomFields();
|
|
||||||
if (!(label in existingFields)) {
|
|
||||||
throw new Error('Could not find label');
|
|
||||||
}
|
|
||||||
|
|
||||||
delete existingFields[label];
|
|
||||||
|
|
||||||
const newCustomFields = await DataProvider.setCustomFields(existingFields);
|
|
||||||
return newCustomFields;
|
|
||||||
};
|
|
||||||
@@ -402,6 +402,7 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
|
|||||||
revision: originalEvent.revision,
|
revision: originalEvent.revision,
|
||||||
timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning,
|
timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning,
|
||||||
timeDanger: patchEvent.timeDanger ?? originalEvent.timeDanger,
|
timeDanger: patchEvent.timeDanger ?? originalEvent.timeDanger,
|
||||||
|
custom: patchEvent.custom ?? originalEvent.custom,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import fs from 'fs';
|
import { unlink, readFileSync } from 'fs';
|
||||||
import { deepmerge } from 'ontime-utils';
|
import { deepmerge } from 'ontime-utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,10 +18,9 @@ export const makeString = (val: unknown, fallback = ''): string => {
|
|||||||
* @param {string} file - reference to file
|
* @param {string} file - reference to file
|
||||||
*/
|
*/
|
||||||
export const deleteFile = async (file) => {
|
export const deleteFile = async (file) => {
|
||||||
// delete a file
|
unlink(file, (error) => {
|
||||||
fs.unlink(file, (err) => {
|
if (error) {
|
||||||
if (err) {
|
console.error('Could not delete file:', error);
|
||||||
console.log(err);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -33,7 +32,7 @@ export const deleteFile = async (file) => {
|
|||||||
*/
|
*/
|
||||||
export const validateFile = (file) => {
|
export const validateFile = (file) => {
|
||||||
try {
|
try {
|
||||||
JSON.parse(fs.readFileSync(file, 'utf-8'));
|
JSON.parse(readFileSync(file, 'utf-8'));
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return false;
|
return false;
|
||||||
@@ -83,9 +82,10 @@ export function mergeObject<T extends object>(a: T, b: Partial<T>): T {
|
|||||||
* @param {object} obj
|
* @param {object} obj
|
||||||
*/
|
*/
|
||||||
export const removeUndefined = (obj: object) => {
|
export const removeUndefined = (obj: object) => {
|
||||||
const patched = {};
|
return Object.keys(obj).reduce((patched, key) => {
|
||||||
Object.keys({ ...obj })
|
if (typeof obj[key] !== 'undefined') {
|
||||||
.filter((key) => typeof obj[key] !== 'undefined')
|
patched[key] = obj[key];
|
||||||
.map((key) => (patched[key] = obj[key]));
|
}
|
||||||
return patched;
|
return patched;
|
||||||
|
}, {});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,375 +0,0 @@
|
|||||||
import { DatabaseModel, LogOrigin } from 'ontime-types';
|
|
||||||
import { ExcelImportMap } from 'ontime-utils';
|
|
||||||
|
|
||||||
import { sheets, sheets_v4 } from '@googleapis/sheets';
|
|
||||||
import { writeFile } from 'fs/promises';
|
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
import { OAuth2Client } from 'google-auth-library';
|
|
||||||
import http from 'http';
|
|
||||||
import { join } from 'path';
|
|
||||||
import { URL } from 'url';
|
|
||||||
|
|
||||||
import { logger } from '../classes/Logger.js';
|
|
||||||
import { getAppDataPath } from '../setup.js';
|
|
||||||
import { ensureDirectory } from './fileManagement.js';
|
|
||||||
import { cellRequestFromEvent, getA1Notation } from './sheetUtils.js';
|
|
||||||
import { parseExcel } from './parser.js';
|
|
||||||
import { parseRundown, parseUserFields } from './parserFunctions.js';
|
|
||||||
import { getRundown } from '../services/rundown-service/rundownUtils.js';
|
|
||||||
|
|
||||||
type ResponseOK = {
|
|
||||||
data: Partial<DatabaseModel>;
|
|
||||||
};
|
|
||||||
|
|
||||||
class Sheet {
|
|
||||||
private static client: null | OAuth2Client = null;
|
|
||||||
private readonly scope = 'https://www.googleapis.com/auth/spreadsheets';
|
|
||||||
private readonly sheetsFolder: string;
|
|
||||||
private readonly clientSecretFile: string;
|
|
||||||
private static clientSecret = null;
|
|
||||||
private static authUrl: null | string = null;
|
|
||||||
private authServerTimeout;
|
|
||||||
|
|
||||||
private readonly requiredClientKeys = [
|
|
||||||
'client_id',
|
|
||||||
'project_id',
|
|
||||||
'auth_uri',
|
|
||||||
'token_uri',
|
|
||||||
'auth_provider_x509_cert_url',
|
|
||||||
'client_secret',
|
|
||||||
'redirect_uris',
|
|
||||||
];
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
const appDataPath = getAppDataPath();
|
|
||||||
|
|
||||||
this.sheetsFolder = join(appDataPath, 'sheets');
|
|
||||||
this.clientSecretFile = join(this.sheetsFolder, 'client_secret.json');
|
|
||||||
ensureDirectory(this.sheetsFolder);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const secrets = JSON.parse(readFileSync(this.clientSecretFile, 'utf-8'));
|
|
||||||
const isKeyMissing = this.requiredClientKeys.some((key) => !(key in secrets['installed']));
|
|
||||||
if (!isKeyMissing) {
|
|
||||||
Sheet.clientSecret = secrets;
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
/* empty - it is ok that there is no clientSecret */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description STEP 1 - saves secrets object to appdata path as client_secret.json
|
|
||||||
* @param {object} secrets
|
|
||||||
* @throws
|
|
||||||
*/
|
|
||||||
public async saveClientSecrets(secrets: object) {
|
|
||||||
Sheet.client = null;
|
|
||||||
Sheet.authUrl = null;
|
|
||||||
Sheet.clientSecret = null;
|
|
||||||
|
|
||||||
const isKeyMissing = this.requiredClientKeys.some((key) => !(key in secrets['installed']));
|
|
||||||
if (isKeyMissing) {
|
|
||||||
throw new Error('Client file is missing some keys');
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(this.clientSecretFile, JSON.stringify(secrets), 'utf-8').catch((err) => {
|
|
||||||
throw new Error(`Unable to save client file to disk ${err}`);
|
|
||||||
});
|
|
||||||
Sheet.clientSecret = secrets;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description STEP 1 - test that the saved object is present
|
|
||||||
*/
|
|
||||||
testClientSecret() {
|
|
||||||
return Sheet.clientSecret !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description STEP 2 - create server to interact with th OAuth2 request
|
|
||||||
* @returns {Promise<string | null>} - returns url path serve on success
|
|
||||||
* @throws
|
|
||||||
*/
|
|
||||||
async openAuthServer(): Promise<string | null> {
|
|
||||||
//TODO: this only works in local networks
|
|
||||||
|
|
||||||
// if the server is already running return it
|
|
||||||
if (Sheet.authUrl) {
|
|
||||||
clearTimeout(this.authServerTimeout);
|
|
||||||
this.authServerTimeout = setTimeout(() => {
|
|
||||||
Sheet.authUrl = null;
|
|
||||||
server.unref();
|
|
||||||
}, 120000);
|
|
||||||
return Sheet.authUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that Secret is valid
|
|
||||||
const keyFile = Sheet.clientSecret;
|
|
||||||
const keys = keyFile.installed || keyFile.web;
|
|
||||||
if (!keys.redirect_uris || keys.redirect_uris.length === 0) {
|
|
||||||
throw new Error('Sheet: Missing redirect URI');
|
|
||||||
}
|
|
||||||
const redirectUri = new URL(keys.redirect_uris[0]);
|
|
||||||
if (redirectUri.hostname !== 'localhost') {
|
|
||||||
throw new Error('Sheet: Invalid redirect URI');
|
|
||||||
}
|
|
||||||
|
|
||||||
// create an oAuth client to authorize the API call
|
|
||||||
const client = new OAuth2Client({
|
|
||||||
clientId: keys.client_id,
|
|
||||||
clientSecret: keys.client_secret,
|
|
||||||
});
|
|
||||||
|
|
||||||
// start the server that will recive the codes
|
|
||||||
const server = http.createServer(async (req, res) => {
|
|
||||||
try {
|
|
||||||
const serverUrl = new URL(req.url, 'http://localhost:3000');
|
|
||||||
if (serverUrl.pathname !== redirectUri.pathname) {
|
|
||||||
res.end('Invalid callback URL');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const searchParams = serverUrl.searchParams;
|
|
||||||
if (searchParams.has('error')) {
|
|
||||||
res.end('Authorization rejected.');
|
|
||||||
logger.info(LogOrigin.Server, `Sheet: ${searchParams.get('error')}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!searchParams.has('code')) {
|
|
||||||
res.end('No authentication code provided.');
|
|
||||||
logger.info(LogOrigin.Server, 'Sheet: Cannot read authentication code');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const code = searchParams.get('code');
|
|
||||||
const { tokens } = await client.getToken({
|
|
||||||
code,
|
|
||||||
redirect_uri: redirectUri.toString(),
|
|
||||||
});
|
|
||||||
client.credentials = tokens;
|
|
||||||
Sheet.client = client;
|
|
||||||
res.end('Authentication successful! Please close this tab and return to OnTime.');
|
|
||||||
logger.info(LogOrigin.Server, 'Sheet: Authentication successful');
|
|
||||||
} catch (e) {
|
|
||||||
logger.error(LogOrigin.Server, `Sheet: ${e}`);
|
|
||||||
} finally {
|
|
||||||
server.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let listenPort = 3000;
|
|
||||||
if (keyFile.installed) {
|
|
||||||
// Use ephemeral port if not a web client
|
|
||||||
listenPort = 0;
|
|
||||||
} else if (redirectUri.port !== '') {
|
|
||||||
listenPort = Number(redirectUri.port);
|
|
||||||
}
|
|
||||||
//TODO: the server might not start correctly
|
|
||||||
server.listen(listenPort);
|
|
||||||
const address = server.address();
|
|
||||||
if (typeof address !== 'string') {
|
|
||||||
redirectUri.port = String(address.port);
|
|
||||||
}
|
|
||||||
// open the browser to the authorize url to start the workflow
|
|
||||||
const authorizeUrl = client.generateAuthUrl({
|
|
||||||
redirect_uri: redirectUri.toString(),
|
|
||||||
access_type: 'offline',
|
|
||||||
scope: this.scope,
|
|
||||||
});
|
|
||||||
Sheet.authUrl = authorizeUrl;
|
|
||||||
this.authServerTimeout = setTimeout(
|
|
||||||
() => {
|
|
||||||
Sheet.authUrl = null;
|
|
||||||
server.unref();
|
|
||||||
},
|
|
||||||
2 * 60 * 1000,
|
|
||||||
);
|
|
||||||
return authorizeUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description STEP 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 STEP 3 - test the given sheet id
|
|
||||||
* @throws
|
|
||||||
*/
|
|
||||||
async testSheetId(sheetId: string) {
|
|
||||||
const spreadsheets = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.get({
|
|
||||||
spreadsheetId: sheetId,
|
|
||||||
includeGridData: false,
|
|
||||||
});
|
|
||||||
if (spreadsheets.status !== 200) {
|
|
||||||
throw new Error(spreadsheets.statusText);
|
|
||||||
}
|
|
||||||
return { worksheetOptions: spreadsheets.data.sheets.map((i) => i.properties.title) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description STEP 4 - test the given worksheet
|
|
||||||
* @throws
|
|
||||||
*/
|
|
||||||
async testWorksheet(sheetId: string, worksheet: string) {
|
|
||||||
const spreadsheets = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.get({
|
|
||||||
spreadsheetId: sheetId,
|
|
||||||
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) {
|
|
||||||
throw new Error(`Request failed: ${spreadsheets.status} ${spreadsheets.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
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}` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description STEP 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,
|
|
||||||
});
|
|
||||||
if (readResponse.status === 200) {
|
|
||||||
const { rundownMetadata } = parseExcel(readResponse.data.values, options);
|
|
||||||
const rundown = getRundown();
|
|
||||||
const titleRow = Object.values(rundownMetadata)[0]['row'];
|
|
||||||
|
|
||||||
const updateRundown = Array<sheets_v4.Schema$Request>();
|
|
||||||
|
|
||||||
// we can't delete the last unfrozen 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 length 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)),
|
|
||||||
);
|
|
||||||
|
|
||||||
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 - Download the rundown from sheet
|
|
||||||
* @param {string} sheetId - 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(sheetId: string, options: ExcelImportMap): Promise<Partial<ResponseOK>> {
|
|
||||||
const { range } = await this.exist(sheetId, options.worksheet);
|
|
||||||
|
|
||||||
const res: Partial<ResponseOK> = {};
|
|
||||||
|
|
||||||
const googleResponse = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.values.get({
|
|
||||||
spreadsheetId: sheetId,
|
|
||||||
valueRenderOption: 'FORMATTED_VALUE',
|
|
||||||
majorDimension: 'ROWS',
|
|
||||||
range,
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: we need to pass this into a service that can safely merge the datasets
|
|
||||||
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.userFields = parseUserFields(dataFromSheet);
|
|
||||||
return res;
|
|
||||||
} else {
|
|
||||||
throw new Error(`Sheet: read failed: ${googleResponse.statusText}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const sheet = new Sheet();
|
|
||||||
@@ -65,11 +65,10 @@ const storage = multer.diskStorage({
|
|||||||
* @argument file - reference to file
|
* @argument file - reference to file
|
||||||
* @return {boolean} - file allowed
|
* @return {boolean} - file allowed
|
||||||
*/
|
*/
|
||||||
const filterAllowed = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
|
const filterUserFile = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
|
||||||
if (file.mimetype.includes(JSON_MIME) || file.mimetype.includes(EXCEL_MIME)) {
|
if (file.mimetype.includes(JSON_MIME) || file.mimetype.includes(EXCEL_MIME)) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
console.error('ERROR: Unrecognised file type');
|
|
||||||
cb(null, false);
|
cb(null, false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -77,5 +76,18 @@ const filterAllowed = (_req: Request, file: Express.Multer.File, cb: FileFilterC
|
|||||||
// Build multer uploader for a single file
|
// Build multer uploader for a single file
|
||||||
export const uploadFile = multer({
|
export const uploadFile = multer({
|
||||||
storage,
|
storage,
|
||||||
fileFilter: filterAllowed,
|
fileFilter: filterUserFile,
|
||||||
}).single('userFile');
|
}).single('userFile');
|
||||||
|
|
||||||
|
const filterClientSecret = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
|
||||||
|
if (file.mimetype.includes(JSON_MIME)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(null, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadClientSecret = multer({
|
||||||
|
storage,
|
||||||
|
fileFilter: filterClientSecret,
|
||||||
|
}).single('client_secret');
|
||||||
|
|||||||
@@ -31,3 +31,5 @@ export type MessageResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ErrorResponse = MessageResponse;
|
export type ErrorResponse = MessageResponse;
|
||||||
|
|
||||||
|
export type AuthenticationStatus = 'authenticated' | 'not_authenticated' | 'pending';
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
export type CustomFieldLabel = string;
|
||||||
|
|
||||||
export type CustomField = {
|
export type CustomField = {
|
||||||
type: string;
|
type: 'string';
|
||||||
label: string;
|
colour: string;
|
||||||
|
label: CustomFieldLabel;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CustomFields = Record<string, CustomField>;
|
export type CustomFields = Record<CustomFieldLabel, CustomField>;
|
||||||
|
export type EventCustomFields = Record<CustomFieldLabel, { value: string }>;
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import { MaybeString } from '../../index.js';
|
import { EventCustomFields, EndAction, MaybeString, TimerType, TimeStrategy } from '../../index.js';
|
||||||
import { EndAction } from '../EndAction.type.js';
|
|
||||||
import { TimerType } from '../TimerType.type.js';
|
|
||||||
import { TimeStrategy } from '../TimeStrategy.type.js';
|
|
||||||
|
|
||||||
export enum SupportedEvent {
|
export enum SupportedEvent {
|
||||||
Event = 'event',
|
Event = 'event',
|
||||||
@@ -56,4 +53,5 @@ export type OntimeEvent = OntimeBaseEvent & {
|
|||||||
delay?: number; // calculated at runtime
|
delay?: number; // calculated at runtime
|
||||||
timeWarning: number;
|
timeWarning: number;
|
||||||
timeDanger: number;
|
timeDanger: number;
|
||||||
|
custom: EventCustomFields;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,7 +31,12 @@ export type { Alias } from './definitions/core/Alias.type.js';
|
|||||||
export type { UserFields } from './definitions/core/UserFields.type.js';
|
export type { UserFields } from './definitions/core/UserFields.type.js';
|
||||||
|
|
||||||
// ---> Custom Fields
|
// ---> Custom Fields
|
||||||
export type { CustomFields, CustomField } from './definitions/core/CustomFields.type.js';
|
export type {
|
||||||
|
CustomFields,
|
||||||
|
CustomField,
|
||||||
|
CustomFieldLabel,
|
||||||
|
EventCustomFields,
|
||||||
|
} from './definitions/core/CustomFields.type.js';
|
||||||
|
|
||||||
// ---> Integration, Subscription
|
// ---> Integration, Subscription
|
||||||
export type { OSCSettings, OscSubscription } from './definitions/core/OscSettings.type.js';
|
export type { OSCSettings, OscSubscription } from './definitions/core/OscSettings.type.js';
|
||||||
@@ -39,6 +44,7 @@ export type { HttpSettings, HttpSubscription } from './definitions/core/HttpSett
|
|||||||
|
|
||||||
// SERVER RESPONSES
|
// SERVER RESPONSES
|
||||||
export type {
|
export type {
|
||||||
|
AuthenticationStatus,
|
||||||
NetworkInterface,
|
NetworkInterface,
|
||||||
GetInfo,
|
GetInfo,
|
||||||
ProjectFileList,
|
ProjectFileList,
|
||||||
|
|||||||
Reference in New Issue
Block a user