diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 111834ed4..bc973e1eb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -89,6 +89,7 @@ While it should allow for a generic setup, it might need to be modified to fit y From the project root, run the following commands - __Install the project dependencies__ by running `pnpm i` +- __Build packages__ by running `pnpm build:localdocker` - __Build docker image from__ by running `docker build -t getontime/ontime` - __Run docker image from compose__ by running `docker-compose up -d` diff --git a/apps/client/src/common/api/apiConstants.ts b/apps/client/src/common/api/apiConstants.ts index f6014cade..45d29be91 100644 --- a/apps/client/src/common/api/apiConstants.ts +++ b/apps/client/src/common/api/apiConstants.ts @@ -8,6 +8,7 @@ export const PROJECT_DATA = ['project']; export const PROJECT_LIST = ['projectList']; export const RUNDOWN = ['rundown']; export const RUNTIME = ['runtimeStore']; +export const SHEET_STATE = ['sheetState']; export const USERFIELDS = ['userFields']; export const VIEW_SETTINGS = ['viewSettings']; diff --git a/apps/client/src/common/api/ontimeApi.ts b/apps/client/src/common/api/ontimeApi.ts index b06aa6692..8737329b4 100644 --- a/apps/client/src/common/api/ontimeApi.ts +++ b/apps/client/src/common/api/ontimeApi.ts @@ -257,3 +257,76 @@ export async function getProjects(): Promise { const res = await axios.get(`${ontimeURL}/projects`); return res.data; } + +/** + * @description STEP 1 + */ +export const uploadSheetClientFile = async (file: File) => { + const formData = new FormData(); + formData.append('userFile', file); + const res = await axios + .post(`${ontimeURL}/sheet/clientsecret`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + .then((response) => response.data.id); + return res; +}; + +/** + * @description STEP 1 test + */ +export const getClientSecrect = async () => { + const response = await axios.get(`${ontimeURL}/sheet/clientsecret`); + return response.data; +}; + +/** + * @description STEP 2 + */ +export const getSheetsAuthUrl = async () => { + const response = await axios.get(`${ontimeURL}/sheet/authentication/url`); + return response.data; +}; + +/** + * @description STEP 2 test + */ +export const getAuthentication = async () => { + const response = await axios.get(`${ontimeURL}/sheet/authentication`); + return response.data; +}; + +/** + * @description STEP 3 + * @returns worksheetOptions + */ +export const postId = async (id: string) => { + const response = await axios.post(`${ontimeURL}/sheet/id`, { id }); + return response.data; +}; + +/** + * @description STEP 4 + */ +export const postWorksheet = async (id: string, worksheet: string) => { + const response = await axios.post(`${ontimeURL}/sheet/worksheet`, { id, worksheet }); + return response.data; +}; + +/** + * @description STEP 5 + */ +export const postPreviewSheet = async (id: string, options: ExcelImportMap) => { + const response = await axios.post(`${ontimeURL}/sheet/pull`, { id, options }); + return response.data.data; +}; + +/** + * @description STEP 5 + */ +export const postPushSheet = async (id: string, options: ExcelImportMap) => { + const response = await axios.post(`${ontimeURL}/sheet-push`, { id, options }); + return response.data.data; +}; diff --git a/apps/client/src/features/editors/Editor.tsx b/apps/client/src/features/editors/Editor.tsx index 24ee4fce6..4c1c9c2f8 100644 --- a/apps/client/src/features/editors/Editor.tsx +++ b/apps/client/src/features/editors/Editor.tsx @@ -7,6 +7,7 @@ import { SettingsOptionId, useSettingsStore } from '../app-settings/settingsStor import MenuBar from '../menu/MenuBar'; import AboutModal from '../modals/about-modal/AboutModal'; import QuickStart from '../modals/quick-start/QuickStart'; +import SheetsModal from '../modals/sheets-modal/SheetsModal'; import UploadModal from '../modals/upload-modal/UploadModal'; import Overview from '../overview/Overview'; @@ -21,22 +22,6 @@ const EventEditor = lazy(() => import('../event-editor/EventEditorExport')); const IntegrationModal = lazy(() => import('../modals/integration-modal/IntegrationModal')); const SettingsModal = lazy(() => import('../modals/settings-modal/SettingsModal')); -// TODO: add breakpoints for body font size ?? -// - 15px for normal -// - 16px for large screens - -// TODO: can we delete all the font-family stuff and leave it only at the top? - -// TODO: change scrollbar colours to use ontime stuff? - -// TODO: rename v2Styles to appStyles? - -// TODO: remove onAir as a setting - -// TODO: add error boundaries - -// TODO: should nav menu have same rules as app settings - export default function Editor() { const showSettings = useSettingsStore((state) => state.showSettings); const setShowSettings = useSettingsStore((state) => state.setShowSettings); @@ -54,6 +39,7 @@ export default function Editor() { } = useDisclosure(); const { isOpen: isAboutModalOpen, onOpen: onAboutModalOpen, onClose: onAboutModalClose } = useDisclosure(); const { isOpen: isQuickStartOpen, onOpen: onQuickStartOpen, onClose: onQuickStartClose } = useDisclosure(); + const { isOpen: isSheetsOpen, onOpen: onSheetsOpen, onClose: onSheetsClose } = useDisclosure(); // Set window title useEffect(() => { @@ -70,8 +56,9 @@ export default function Editor() { + -
+
{showSettings ? ( diff --git a/apps/client/src/features/menu/MenuBar.tsx b/apps/client/src/features/menu/MenuBar.tsx index 91cbc1779..cddecd686 100644 --- a/apps/client/src/features/menu/MenuBar.tsx +++ b/apps/client/src/features/menu/MenuBar.tsx @@ -1,4 +1,6 @@ import { memo, useCallback, useEffect, useState } from 'react'; +import { IoCloud } from '@react-icons/all-files/io5/IoCloud'; +import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline'; import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand'; import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle'; import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline'; @@ -30,6 +32,8 @@ interface MenuBarProps { onAboutOpen: () => void; isQuickStartOpen: boolean; onQuickStartOpen: () => void; + isSheetsOpen: boolean; + onSheetsOpen: () => void; openSettings: (newTab?: string) => void; isSettingsOpen: boolean; } @@ -61,6 +65,8 @@ const MenuBar = (props: MenuBarProps) => { onQuickStartOpen, openSettings, isSettingsOpen, + isSheetsOpen, + onSheetsOpen, } = props; const { isElectron, sendToElectron } = useElectronEvent(); @@ -177,6 +183,16 @@ const MenuBar = (props: MenuBarProps) => { />
+ : } + className={isSheetsOpen ? style.open : ''} + clickHandler={onSheetsOpen} + tooltip='Sheets' + aria-label='Sheets' + size='sm' + /> void; + isOpen: boolean; +} + +export default function SheetsModal(props: SheetsModalProps) { + const { isOpen, onClose } = props; + + const queryClient = useQueryClient(); + + const [rundown, setRundown] = useState(null); + const [userFields, setUserFields] = useState(null); + const [project, setProject] = useState(null); + + const [id, setSheetId] = useState(''); + const [worksheet, setWorksheet] = useState(''); + const [worksheetOptions, setWorksheetOptions] = useState([]); + + const [direction, setDirection] = useState('none'); + const excelFileOptions = useRef(defaultExcelImportMap); + + const fileInputRef = useRef(null); + + const [state, setState] = useState({ + clientSecret: { complete: false, message: '' }, + authenticate: { complete: false, message: '' }, + id: { complete: false, message: '' }, + worksheet: { complete: false, message: '' }, + pullPush: { complete: false, message: '' }, + }); + + useEffect(() => { + if (isOpen) { + setDirection('none'); + testClientSecret(); + if (state.clientSecret.complete) testAuthentication(); + if (state.authenticate.complete) testSheetId(); + } + }, [isOpen]); + + const handleClose = () => { + setRundown(null); + setProject(null); + setUserFields(null); + onClose(); + }; + + //SETP-1 Upload Client ID + const handleClick = () => { + fileInputRef.current?.click(); + }; + + const handleFile = (event: ChangeEvent) => { + if (!event.target.files?.length) { + setState({ + clientSecret: { complete: false, message: 'Missing file' }, + authenticate: { complete: false, message: '' }, + id: { complete: false, message: '' }, + worksheet: { complete: false, message: '' }, + pullPush: { complete: false, message: '' }, + }); + return; + } + const selectedFile = event.target.files[0]; + uploadSheetClientFile(selectedFile) + .then(() => { + setState({ ...state, clientSecret: { complete: true, message: '' } }); + }) + .catch((err) => { + const message = maybeAxiosError(err); + setState({ + clientSecret: { complete: false, message }, + authenticate: { complete: false, message: '' }, + id: { complete: false, message: '' }, + worksheet: { complete: false, message: '' }, + pullPush: { complete: false, message: '' }, + }); + }); + }; + + const testClientSecret = () => { + getClientSecrect() + .then(() => { + setState({ ...state, clientSecret: { complete: true, message: '' } }); + }) + .catch((err) => { + const message = maybeAxiosError(err); + setState({ + clientSecret: { complete: false, message }, + authenticate: { complete: false, message: '' }, + id: { complete: false, message: '' }, + worksheet: { complete: false, message: '' }, + pullPush: { complete: false, message: '' }, + }); + }); + }; + + //SETP-2 Authenticate + const handleAuthenticate = () => { + getSheetsAuthUrl() + .then((data) => { + openLink(data); + window.addEventListener('focus', () => testAuthentication(), { once: true }); + }) + .catch((err) => { + const message = maybeAxiosError(err); + setState({ + ...state, + authenticate: { complete: false, message }, + id: { complete: false, message: '' }, + worksheet: { complete: false, message: '' }, + pullPush: { complete: false, message: '' }, + }); + }); + }; + + const testAuthentication = () => { + getAuthentication() + .then(() => { + setState({ ...state, authenticate: { complete: true, message: '' } }); + }) + .catch((error) => { + const message = maybeAxiosError(error); + setState({ + ...state, + authenticate: { complete: false, message }, + id: { complete: false, message: '' }, + worksheet: { complete: false, message: '' }, + pullPush: { complete: false, message: '' }, + }); + }); + }; + + //SETP-3 set sheet ID + const testSheetId = () => { + postId(id) + .then((data) => { + setState({ ...state, id: { complete: true, message: '' } }); + setWorksheetOptions(data.worksheetOptions); + }) + .catch((err) => { + const message = maybeAxiosError(err); + setState({ + ...state, + id: { complete: false, message }, + worksheet: { complete: false, message: '' }, + pullPush: { complete: false, message: '' }, + }); + setWorksheetOptions([]); + }); + }; + + //SETP-4 Select Worksheet + const testWorksheet = (value: string) => { + excelFileOptions.current.worksheet = value; + setWorksheet(value); + postWorksheet(id, worksheet) + .then(() => { + setState({ ...state, worksheet: { complete: true, message: '' } }); + }) + .catch((err) => { + const message = maybeAxiosError(err); + setState({ ...state, worksheet: { complete: false, message }, pullPush: { complete: false, message: '' } }); + }); + }; + + //SETP-5 Upload / Download + const updateExcelFileOptions = (field: T, value: ExcelImportMap[T]) => { + if (excelFileOptions.current[field] !== value) { + excelFileOptions.current = { ...excelFileOptions.current, [field]: value }; + } + }; + + const handlePullData = () => { + postPreviewSheet(id, excelFileOptions.current) + .then((data) => { + setProject(data.project); + setRundown(data.rundown); + setUserFields(data.userFields); + }) + .catch((error) => { + const message = maybeAxiosError(error); + setDirection('none'); + setState({ ...state, pullPush: { complete: false, message } }); + }); + }; + + const handlePushData = () => { + postPushSheet(id, excelFileOptions.current) + .then(() => { + setDirection('none'); + setState({ ...state, pullPush: { complete: true, message: '' } }); + }) + .catch((error) => { + const message = maybeAxiosError(error); + setDirection('none'); + setState({ ...state, pullPush: { complete: false, message } }); + }); + }; + + //GET preview + const handleFinalise = async () => { + if (rundown && userFields && project) { + let doClose = false; + try { + await patchData({ rundown, userFields, project }); + queryClient.setQueryData(RUNDOWN, rundown); + queryClient.setQueryData(USERFIELDS, userFields); + queryClient.setQueryData(PROJECT_DATA, project); + await queryClient.invalidateQueries({ + queryKey: [...RUNDOWN, ...USERFIELDS, ...PROJECT_DATA], + }); + doClose = true; + } catch (error) { + const message = maybeAxiosError(error); + console.error(message); + } finally { + if (doClose) { + handleClose(); + } + } + } + }; + + return ( + + + + Rundown from sheets (experimental) + + + + +
+ Sync with Google Sheets + + + For more information, see the docs + + +
+
+ {!rundown ? ( + direction === 'up' || direction === 'down' ? ( + + ) : ( + <> + + +
+ + +
+
+ + +
+ + +
+
+ + + + setSheetId(event.target.value)} + /> + + + + + + + + + +
+ + +
+
+ + ) + ) : ( + + )} +
+ + {rundown ? ( +
+ + +
+ ) : direction === 'up' ? ( +
+ + +
+ ) : direction === 'down' ? ( +
+ + +
+ ) : ( + <> + )} +
+
+
+ ); +} diff --git a/apps/client/src/features/modals/sheets-modal/Step.module.scss b/apps/client/src/features/modals/sheets-modal/Step.module.scss new file mode 100644 index 000000000..5b2e3b9c3 --- /dev/null +++ b/apps/client/src/features/modals/sheets-modal/Step.module.scss @@ -0,0 +1,42 @@ +@use '../../../theme/ontimeColours' as *; + +.wrapper { + padding: 0.5rem; +} + +.header { + width: 100%; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + padding-bottom: 1rem; +} + +.step { + width: 1.25rem; + height: 1.25rem; + font-size: 0.75rem; + display: grid; + place-content: center; +} + +.title { + display: inline; + color: $gray-500; + text-transform: uppercase; + cursor: pointer; +} + +.check { + color: $green-700; +} + +.errorIcon { + color: $red-700; +} + +.errorText { + color: $red-700; + font-size: 0.75rem; +} diff --git a/apps/client/src/features/modals/sheets-modal/Step.tsx b/apps/client/src/features/modals/sheets-modal/Step.tsx new file mode 100644 index 000000000..28f16b219 --- /dev/null +++ b/apps/client/src/features/modals/sheets-modal/Step.tsx @@ -0,0 +1,47 @@ +import { PropsWithChildren, useEffect, useMemo, useState } from 'react'; +import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle'; +import { IoCloseCircle } from '@react-icons/all-files/io5/IoCloseCircle'; +import { IoRadioButtonOffOutline } from '@react-icons/all-files/io5/IoRadioButtonOffOutline'; + +import style from './Step.module.scss'; + +interface StepProps { + title: string; + disabled: boolean; + completed: boolean; + error?: string; +} + +export default function Step(props: PropsWithChildren) { + const { title, disabled, completed, error, children } = props; + const [collapsed, setCollapsed] = useState(disabled); + + const handleCollapse = () => setCollapsed((prev) => !prev); + + const icon = useMemo(() => { + if (completed) return ; + if (error) return ; + return ; + }, [completed, error]); + + useEffect(() => { + if (completed) { + setCollapsed(true); + } + }, [completed]); + + return ( +
+
+ {icon} + {title} +
+ {!collapsed && ( + <> + {error &&
{error}
} + {children} + + )} +
+ ); +} diff --git a/apps/client/src/features/rundown/event-block/EventBlock.module.scss b/apps/client/src/features/rundown/event-block/EventBlock.module.scss index 7ad792686..a28bfe09d 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.module.scss +++ b/apps/client/src/features/rundown/event-block/EventBlock.module.scss @@ -207,7 +207,7 @@ $skip-opacity: 0.1; gap: 0.5rem; .indicator { - background-color: $gray-1250; + background-color: transparent; margin: 0.4rem; margin-right: 0; border-radius: 0.7rem; @@ -217,15 +217,11 @@ $skip-opacity: 0.1; .indicator.delay { background-color: $ontime-delay; } - .indicator.overlap { - background-color: $gray-600; - } + .indicator.nextDay, + .indicator.overlap, .indicator.spacing { background-color: $gray-600; } - .indicator.nextDay { - background-color: $gray-600; - } } .eventStatus { diff --git a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx index 65e31c61a..e6d31d510 100644 --- a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx @@ -137,29 +137,28 @@ const EventBlockInner = (props: EventBlockInnerProps) => { ) : ( - - {delayTime} -
+ {delayTime}
New Time: {newTime}
- ) - } - > -
- - -
0 ? style.overlap : overlap < 0 && overlapTime !== null ? style.spacing : '' - }`} - /> - - -
timeEnd ? style.nextDay : ''}`} /> - + } + > +
+ + )} + {overlapTime && ( + +
0 ? style.overlap : style.spacing}`} /> + + )} + {timeStart > timeEnd && ( + +
+ + )} )} { res.status(404).send(`${req.originalUrl} not found`); }); @@ -129,6 +138,7 @@ export const initAssets = async () => { checkStart(OntimeStartOrder.InitAssets); await dbLoadingProcess; populateStyles(); + populateDemo(); }; /** diff --git a/apps/server/src/classes/data-provider/__test__/DataProvider.test.ts b/apps/server/src/classes/data-provider/__test__/DataProvider.test.ts index bcd83db7a..07b1ce0e8 100644 --- a/apps/server/src/classes/data-provider/__test__/DataProvider.test.ts +++ b/apps/server/src/classes/data-provider/__test__/DataProvider.test.ts @@ -97,7 +97,6 @@ describe('safeMerge', () => { language: 'pt', }); }); - it('merges the osc key', () => { const newData = { osc: { diff --git a/apps/server/src/config/config.js b/apps/server/src/config/config.js index f11da3cdc..ac79ed456 100644 --- a/apps/server/src/config/config.js +++ b/apps/server/src/config/config.js @@ -8,5 +8,9 @@ export const config = { directory: 'styles', filename: 'override.css', }, + demo: { + directory: 'demo', + filename: ['app.js', 'index.html', 'styles.css'], + }, restoreFile: 'ontime.restore', }; diff --git a/apps/server/src/controllers/integrationController.ts b/apps/server/src/controllers/integrationController.ts index a129cf6b0..105745013 100644 --- a/apps/server/src/controllers/integrationController.ts +++ b/apps/server/src/controllers/integrationController.ts @@ -137,7 +137,10 @@ const actionHandlers: Record = { } // Indexes in frontend are 1 based - PlaybackService.startByIndex(eventIndex - 1); + const success = PlaybackService.startByIndex(eventIndex - 1); + if (!success) { + throw new Error(`Event index not recognised or out of range ${eventIndex}`); + } return { payload: 'success' }; }, startid: (payload) => { @@ -179,7 +182,6 @@ const actionHandlers: Record = { if (eventIndex <= 0) { throw new Error(`Event index out of range ${eventIndex}`); } - // Indexes in frontend are 1 based PlaybackService.loadByIndex(eventIndex - 1); return { payload: 'success' }; @@ -200,7 +202,6 @@ const actionHandlers: Record = { return { payload: 'success' }; }, }; - /** * Returns a value of type number, converting if necessary * Otherwise throws diff --git a/apps/server/src/controllers/ontimeController.ts b/apps/server/src/controllers/ontimeController.ts index 87efa7224..1de3f64b7 100644 --- a/apps/server/src/controllers/ontimeController.ts +++ b/apps/server/src/controllers/ontimeController.ts @@ -42,6 +42,8 @@ import { deleteFile } from '../utils/parserUtils.js'; import { validateProjectFiles } from './ontimeController.validate.js'; import { dbModel } from '../models/dataModel.js'; +import { sheet } from '../utils/sheetsAuth.js'; + // Create controller for GET request to '/ontime/poll' // Returns data for current state export const poll = async (_req, res) => { @@ -668,3 +670,122 @@ export const deleteProjectFile: RequestHandler = async (req, res) => { res.status(500).send({ message: error.toString() }); } }; + +// SHEET Functions +/** + * @description SETP-1 POST Client Secrect + * @returns parsed result + */ +export async function uploadSheetClientFile(req, res) { + if (!req.file.path) { + res.status(400).send({ message: 'File not found' }); + return; + } + try { + const client = JSON.parse(fs.readFileSync(req.file.path as string, 'utf-8')); + await sheet.saveClientSecrets(client); + res.status(200).send('OK'); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } + fs.unlink(req.file.path, (err) => { + if (err) logger.error(LogOrigin.Server, err.message); + }); +} + +/** + * @description STEP-1 GET Client Secrect status + */ +export const getClientSecrect = async (req, res) => { + try { + const clientSecrectExists = await sheet.testClientSecret(); + if (clientSecrectExists) { + res.status(200).send(); + } else { + res.status(500).send({ message: 'The Client ID does not exist' }); + } + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +}; + +/** + * @description STEP-2 GET sheet authentication url + */ +export async function getAuthenticationUrl(req, res) { + try { + const authUrl = await sheet.openAuthServer(); + res.status(200).send(authUrl); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +} + +/** + * @description STEP-2 GET sheet authentication status + */ +export const getAuthentication = async (req, res) => { + try { + await sheet.testAuthentication(); + res.status(200).send(); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +}; + +/** + * @description STEP-3 POST sheet id + * @returns list of worksheets + */ +export const postId = async (req, res) => { + try { + const { id } = req.body; + if (id.lenght < 40) { + res.status(400).send({ message: 'ID is usualy 44 characters long' }); + } + const state = await sheet.testSheetId(id); + res.status(200).send(state); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +}; + +/** + * @description STEP-4 POST worksheet + */ +export const postWorksheet = async (req, res) => { + try { + const { worksheet, id } = req.body; + const state = await sheet.testWorksheet(worksheet, id); + res.status(200).send(state); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +}; + +/** + * @description STEP-5 POST download undown to sheet + * @returns parsed result + */ +export async function pullSheet(req, res) { + try { + const { id, options } = req.body; + const data = await sheet.pull(id, options); + res.status(200).send(data); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +} + +/** + * @description STEP-5 POST upload rundown to sheet + */ +export async function pushSheet(req, res) { + try { + const { id, options } = req.body; + await sheet.push(id, options); + res.status(200).send(); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +} diff --git a/apps/server/src/controllers/ontimeController.validate.ts b/apps/server/src/controllers/ontimeController.validate.ts index b331627cf..531b2bd46 100644 --- a/apps/server/src/controllers/ontimeController.validate.ts +++ b/apps/server/src/controllers/ontimeController.validate.ts @@ -266,3 +266,32 @@ export const validateProjectFiles = (projectFiles: { filename?: string; newFilen return errors; }; + +export const validateSheetid = [ + body('id').exists().isString(), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; + +export const validateWorksheet = [ + body('id').exists().isString(), + body('worksheet').exists().isString(), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; + +export const validateSheetOptions = [ + body('id').exists().isString(), + // body('options').exists().isObject(), TODO: + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; diff --git a/apps/server/src/modules/loadDemo.ts b/apps/server/src/modules/loadDemo.ts new file mode 100644 index 000000000..84d4361bf --- /dev/null +++ b/apps/server/src/modules/loadDemo.ts @@ -0,0 +1,21 @@ +import { copyFile } from 'fs/promises'; +import { pathToStartDemo, resolveDemoDirectory, resolveDemoPath } from '../setup.js'; +import { ensureDirectory } from '../utils/fileManagement.js'; + +/** + * @description ensures directories exist and populates demo folder + */ +export const populateDemo = () => { + ensureDirectory(resolveDemoDirectory); + // even if demo exist we want to use startup demo + try { + Promise.all( + resolveDemoPath.map((to, index) => { + const from = pathToStartDemo[index]; + copyFile(from, to); + }), + ); + } catch (_) { + /* we do not handle this */ + } +}; diff --git a/apps/server/src/modules/loadStyles.ts b/apps/server/src/modules/loadStyles.ts index 7bf6bf817..f68f716e1 100644 --- a/apps/server/src/modules/loadStyles.ts +++ b/apps/server/src/modules/loadStyles.ts @@ -4,20 +4,15 @@ import { ensureDirectory } from '../utils/fileManagement.js'; /** * @description ensures directories exist and populates stylesheet - * @return {string} - path to stylesheet file */ export const populateStyles = () => { - const stylesInDisk = resolveStylesPath; ensureDirectory(resolveStylesDirectory); - - // if stylesInDisk doesn't exist we want to use startup stylesheet - if (!existsSync(stylesInDisk)) { + // if styles doesn't exist we want to use startup stylesheet + if (!existsSync(resolveStylesPath)) { try { - copyFileSync(pathToStartStyles, stylesInDisk); + copyFileSync(pathToStartStyles, resolveStylesPath); } catch (_) { /* we do not handle this */ } } - - return stylesInDisk; }; diff --git a/apps/server/src/routes/ontimeRouter.ts b/apps/server/src/routes/ontimeRouter.ts index 72a527395..2595d4876 100644 --- a/apps/server/src/routes/ontimeRouter.ts +++ b/apps/server/src/routes/ontimeRouter.ts @@ -27,6 +27,13 @@ import { renameProjectFile, createProjectFile, deleteProjectFile, + getAuthenticationUrl, + uploadSheetClientFile as uploadClientSecret, + pullSheet, + pushSheet, + postId, + getAuthentication, + getClientSecrect as getClientSecret, } from '../controllers/ontimeController.js'; import { @@ -42,6 +49,9 @@ import { validateLoadProjectFile, validateProjectRename, validateProjectCreate, + validateSheetid, + validateWorksheet, + validateSheetOptions, } from '../controllers/ontimeController.validate.js'; import { projectSanitiser } from '../controllers/projectController.validate.js'; import { sanitizeProjectFilename } from '../utils/sanitizeProjectFilename.js'; @@ -125,3 +135,23 @@ router.post('/project', validateProjectCreate, sanitizeProjectFilename, createPr // create route between controller and '/ontime/project/:filename' endpoint router.delete('/project/:filename', sanitizeProjectFilename, deleteProjectFile); + +// Google Sheet integration - Step 1 +router.post('/sheet/clientsecret', uploadFile, uploadClientSecret); +router.get('/sheet/clientsecret', uploadFile, getClientSecret); + +// Google Sheet integration - Step 1 +router.get('/sheet/authentication/url', getAuthenticationUrl); +router.get('/sheet/authentication', getAuthentication); + +// Google Sheet integration - Step 3 +router.post('/sheet/id', validateSheetid, postId); + +// Google Sheet integration - Step 4 +router.post('/sheet/worksheet', validateWorksheet, postId); + +// Google Sheet integration - Step 5 +router.post('/sheet/pull', validateSheetOptions, pullSheet); + +// Google Sheet integration - Step 6 +router.post('/sheet-push', validateSheetOptions, pushSheet); diff --git a/apps/server/src/setup.ts b/apps/server/src/setup.ts index 56348df33..001aeb001 100644 --- a/apps/server/src/setup.ts +++ b/apps/server/src/setup.ts @@ -71,10 +71,16 @@ export const currentDirectory = dirname(__dirname); const testDbStartDirectory = isTest ? '../' : getAppDataPath(); export const externalsStartDirectory = isProduction ? getAppDataPath() : join(currentDirectory, 'external'); +//TODO: we only need one when they are all in the same folder +export const resolveExternalsDirectory = join(isProduction ? getAppDataPath() : currentDirectory, 'external'); +// path to public db +export const resolveDbDirectory = join(testDbStartDirectory, isTest ? config.database.testdb : configDbDirectory); +export const resolveDbPath = join(resolveDbDirectory, lastLoadedProject ? lastLoadedProject : config.database.filename); + +// project files export const lastLoadedProjectConfigPath = join(getAppDataPath(), 'config.json'); export const uploadsFolderPath = join(getAppDataPath(), 'uploads'); - let lastLoadedProject; try { @@ -88,19 +94,30 @@ try { const configDbDirectory = lastLoadedProject ? 'uploads' : config.database.directory; -// path to public db -export const resolveDbDirectory = join(testDbStartDirectory, isTest ? config.database.testdb : configDbDirectory); -export const resolveDbPath = join(resolveDbDirectory, lastLoadedProject ? lastLoadedProject : config.database.filename); - export const pathToStartDb = isTest ? join(currentDirectory, '../', config.database.testdb, config.database.filename) : join(currentDirectory, '/preloaded-db/', config.database.filename); +//TODO: move all static files to the external directory // path to public styles export const resolveStylesDirectory = join(externalsStartDirectory, config.styles.directory); export const resolveStylesPath = join(resolveStylesDirectory, config.styles.filename); export const pathToStartStyles = join(currentDirectory, '/external/styles/', config.styles.filename); +// path to public demo +export const resolveDemoDirectory = join( + externalsStartDirectory, + isProduction ? '/external/' : '', //move to external folde in production + config.demo.directory, +); +export const resolveDemoPath = config.demo.filename.map((file) => { + return join(resolveDemoDirectory, file); +}); + +export const pathToStartDemo = config.demo.filename.map((file) => { + return join(currentDirectory, '/external/demo/', file); +}); + // path to restore file export const resolveRestoreFile = join(getAppDataPath(), config.restoreFile); diff --git a/apps/server/src/utils/__tests__/sheetUtils.test.ts b/apps/server/src/utils/__tests__/sheetUtils.test.ts new file mode 100644 index 000000000..63708a66b --- /dev/null +++ b/apps/server/src/utils/__tests__/sheetUtils.test.ts @@ -0,0 +1,433 @@ +import { millisToString } from 'ontime-utils'; +import { getA1Notation, cellRequestFromEvent, cellRequenstFromProjectData } from '../sheetUtils.js'; +import { EndAction, OntimeRundownEntry, ProjectData, SupportedEvent, TimerType } from 'ontime-types'; + +describe('getA1Notation()', () => { + test('A1', () => { + expect(getA1Notation(0, 0)).toStrictEqual('A1'); + }); + test('E3', () => { + expect(getA1Notation(2, 4)).toStrictEqual('E3'); + }); + test('AA100', () => { + expect(getA1Notation(99, 26)).toStrictEqual('AA100'); + }); + test('can not be negative', () => { + expect(() => getA1Notation(-1, 1)).toThrowError('Index can not be less than 0'); + }); +}); + +describe('cellRequenstFromEvent()', () => { + test('string to string', () => { + const event: OntimeRundownEntry = { + type: SupportedEvent.Event, + cue: '1', + title: 'Fancy', + subtitle: 'Wow', + presenter: 'Mr. Presenter', + note: 'Blue button on the right', + timeStart: 46800000, + timeEnd: 57600000, + endAction: EndAction.None, + timerType: TimerType.CountDown, + duration: 10800000, + isPublic: false, + skip: false, + colour: 'red', + user0: '', + user1: '', + user2: '', + user3: '', + user4: '', + user5: '', + user6: '', + user7: '', + user8: '', + user9: '', + revision: 0, + id: '1358', + }; + const metadata = { + type: { row: 1, col: 14 }, + cue: { row: 1, col: 15 }, + title: { row: 1, col: 16 }, + subtitle: { row: 1, col: 17 }, + presenter: { row: 1, col: 18 }, + note: { row: 1, col: 19 }, + timeStart: { row: 1, col: 20 }, + timeEnd: { row: 1, col: 21 }, + endAction: { row: 1, col: 22 }, + timerType: { row: 1, col: 23 }, + duration: { row: 1, col: 24 }, + isPublic: { row: 1, col: 25 }, + skip: { row: 1, col: 26 }, + colour: { row: 1, col: 27 }, + user0: { row: 1, col: 28 }, + user1: { row: 1, col: 29 }, + user2: { row: 1, col: 30 }, + user3: { row: 1, col: 31 }, + user4: { row: 1, col: 32 }, + user5: { row: 1, col: 33 }, + user6: { row: 1, col: 34 }, + user7: { row: 1, col: 35 }, + user8: { row: 1, col: 36 }, + user9: { row: 1, col: 37 }, + revision: { row: 1, col: 38 }, + id: { row: 1, col: 39 }, + }; + const result = cellRequestFromEvent(event, 1, 1234, metadata); + expect(result.updateCells.rows[0].values[5].userEnteredValue.stringValue).toStrictEqual(event.note); + }); + + test('numer to timer', () => { + const event: OntimeRundownEntry = { + type: SupportedEvent.Event, + cue: '1', + title: 'Fancy', + subtitle: 'Wow', + presenter: 'Mr. Presenter', + note: 'Blue button on the right', + timeStart: 46800000, + timeEnd: 57600000, + endAction: EndAction.None, + timerType: TimerType.CountDown, + duration: 10800000, + isPublic: false, + skip: false, + colour: 'red', + user0: '', + user1: '', + user2: '', + user3: '', + user4: '', + user5: '', + user6: '', + user7: '', + user8: '', + user9: '', + revision: 0, + id: '1358', + }; + const metadata = { + type: { row: 1, col: 14 }, + cue: { row: 1, col: 15 }, + title: { row: 1, col: 16 }, + subtitle: { row: 1, col: 17 }, + presenter: { row: 1, col: 18 }, + note: { row: 1, col: 19 }, + timeStart: { row: 1, col: 20 }, + timeEnd: { row: 1, col: 21 }, + endAction: { row: 1, col: 22 }, + timerType: { row: 1, col: 23 }, + duration: { row: 1, col: 24 }, + isPublic: { row: 1, col: 25 }, + skip: { row: 1, col: 26 }, + colour: { row: 1, col: 27 }, + user0: { row: 1, col: 28 }, + user1: { row: 1, col: 29 }, + user2: { row: 1, col: 30 }, + user3: { row: 1, col: 31 }, + user4: { row: 1, col: 32 }, + user5: { row: 1, col: 33 }, + user6: { row: 1, col: 34 }, + user7: { row: 1, col: 35 }, + user8: { row: 1, col: 36 }, + user9: { row: 1, col: 37 }, + revision: { row: 1, col: 38 }, + id: { row: 1, col: 39 }, + }; + const result = cellRequestFromEvent(event, 1, 1234, metadata).updateCells.rows[0].values[10].userEnteredValue + .stringValue; + expect(result).toStrictEqual(millisToString(event.duration)); + }); + + test('boolean to x', () => { + const event: OntimeRundownEntry = { + type: SupportedEvent.Event, + cue: '1', + title: 'Fancy', + subtitle: 'Wow', + presenter: 'Mr. Presenter', + note: 'Blue button on the right', + timeStart: 46800000, + timeEnd: 57600000, + endAction: EndAction.None, + timerType: TimerType.CountDown, + duration: 10800000, + isPublic: true, + skip: false, + colour: 'red', + user0: 'u', + user1: 'u', + user2: 'u', + user3: 'u', + user4: 'u', + user5: 'u', + user6: 'u', + user7: 'u', + user8: 'u', + user9: 'u', + revision: 0, + id: '1358', + }; + const metadata = { + type: { row: 1, col: 14 }, + cue: { row: 1, col: 15 }, + title: { row: 1, col: 16 }, + subtitle: { row: 1, col: 17 }, + presenter: { row: 1, col: 18 }, + note: { row: 1, col: 19 }, + timeStart: { row: 1, col: 20 }, + timeEnd: { row: 1, col: 21 }, + endAction: { row: 1, col: 22 }, + timerType: { row: 1, col: 23 }, + duration: { row: 1, col: 24 }, + isPublic: { row: 1, col: 25 }, + skip: { row: 1, col: 26 }, + colour: { row: 1, col: 27 }, + user0: { row: 1, col: 28 }, + user1: { row: 1, col: 29 }, + user2: { row: 1, col: 30 }, + user3: { row: 1, col: 31 }, + user4: { row: 1, col: 32 }, + user5: { row: 1, col: 33 }, + user6: { row: 1, col: 34 }, + user7: { row: 1, col: 35 }, + user8: { row: 1, col: 36 }, + user9: { row: 1, col: 37 }, + revision: { row: 1, col: 38 }, + id: { row: 1, col: 39 }, + }; + const result = cellRequestFromEvent(event, 1, 1234, metadata); + expect(result.updateCells.rows[0].values[11].userEnteredValue.stringValue).toStrictEqual('x'); + expect(result.updateCells.rows[0].values[12].userEnteredValue.stringValue).toStrictEqual(''); + }); + + test('spacing in metadata', () => { + const event: OntimeRundownEntry = { + type: SupportedEvent.Event, + cue: '1', + title: 'Fancy', + subtitle: 'Wow', + presenter: 'Mr. Presenter', + note: 'Blue button on the right', + timeStart: 46800000, + timeEnd: 57600000, + endAction: EndAction.None, + timerType: TimerType.CountDown, + duration: 10800000, + isPublic: true, + skip: false, + colour: 'red', + user0: 'u', + user1: 'u', + user2: 'u', + user3: 'u', + user4: 'u', + user5: 'u', + user6: 'u', + user7: 'u', + user8: 'u', + user9: 'u', + revision: 0, + id: '1358', + }; + const metadata = { + cue: { row: 1, col: 0 }, + title: { row: 1, col: 6 }, + subtitle: { row: 1, col: 10 }, + user0: { row: 1, col: 16 }, + }; + const result = cellRequestFromEvent(event, 1, 1234, metadata); + expect(result.updateCells.rows[0].values[0].userEnteredValue.stringValue).toStrictEqual(event.cue); + expect(result.updateCells.rows[0].values[6].userEnteredValue.stringValue).toStrictEqual(event.title); + expect(result.updateCells.rows[0].values[10].userEnteredValue.stringValue).toStrictEqual(event.subtitle); + }); + + test('metadata offset from zero', () => { + const event: OntimeRundownEntry = { + type: SupportedEvent.Event, + cue: '1', + title: 'Fancy', + subtitle: 'Wow', + presenter: 'Mr. Presenter', + note: 'Blue button on the right', + timeStart: 46800000, + timeEnd: 57600000, + endAction: EndAction.None, + timerType: TimerType.CountDown, + duration: 10800000, + isPublic: true, + skip: false, + colour: 'red', + user0: 'u', + user1: 'u', + user2: 'u', + user3: 'u', + user4: 'u', + user5: 'u', + user6: 'u', + user7: 'u', + user8: 'u', + user9: 'u', + revision: 0, + id: '1358', + }; + const metadata = { + cue: { row: 1, col: 5 }, + title: { row: 1, col: 6 }, + subtitle: { row: 1, col: 10 }, + user0: { row: 1, col: 16 }, + }; + const result = cellRequestFromEvent(event, 1, 1234, metadata); + expect(result.updateCells.rows[0].values[0].userEnteredValue.stringValue).toStrictEqual(event.cue); + expect(result.updateCells.rows[0].values[1].userEnteredValue.stringValue).toStrictEqual(event.title); + expect(result.updateCells.rows[0].values[5].userEnteredValue.stringValue).toStrictEqual(event.subtitle); + }); + + test('sheet setup', () => { + const event: OntimeRundownEntry = { + type: SupportedEvent.Event, + cue: '1', + title: 'Fancy', + subtitle: 'Wow', + presenter: 'Mr. Presenter', + note: 'Blue button on the right', + timeStart: 46800000, + timeEnd: 57600000, + endAction: EndAction.None, + timerType: TimerType.CountDown, + duration: 10800000, + isPublic: true, + skip: false, + colour: 'red', + user0: 'u', + user1: 'u', + user2: 'u', + user3: 'u', + user4: 'u', + user5: 'u', + user6: 'u', + user7: 'u', + user8: 'u', + user9: 'u', + revision: 0, + id: '1358', + }; + const metadata = { + cue: { row: 10, col: 5 }, + title: { row: 10, col: 6 }, + subtitle: { row: 1, col: 10 }, + user0: { row: 10, col: 16 }, + }; + const result1 = cellRequestFromEvent(event, 1, 1234, metadata); + expect(result1.updateCells.start.sheetId).toStrictEqual(1234); + const result2 = cellRequestFromEvent(event, 10, 1234, metadata); + expect(result2.updateCells.start.rowIndex).toStrictEqual(21); + expect(result2.updateCells.start.columnIndex).toStrictEqual(5); + expect(result2.updateCells.fields).toStrictEqual('userEnteredValue'); + }); +}); + +describe('cellRequenstFromProjectData()', () => { + test('string to string', () => { + const projectData: ProjectData = { + title: 'Title', + description: 'Description', + publicUrl: 'Public Url', + backstageUrl: 'Backstage Url', + publicInfo: 'Public Info', + backstageInfo: 'Backstage Info', + }; + const metadata = { + title: { row: 0, col: 1 }, + description: { row: 1, col: 1 }, + publicUrl: { row: 2, col: 1 }, + backstageUrl: { row: 3, col: 1 }, + publicInfo: { row: 4, col: 1 }, + backstageInfo: { row: 5, col: 1 }, + }; + const result = cellRequenstFromProjectData(projectData, 1234, metadata); + expect(result.updateCells.rows[0].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.title); + expect(result.updateCells.rows[1].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.description); + expect(result.updateCells.rows[2].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.publicUrl); + expect(result.updateCells.rows[3].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.backstageUrl); + expect(result.updateCells.rows[4].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.publicInfo); + expect(result.updateCells.rows[5].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.backstageInfo); + }); + + test('metadata offset from zero', () => { + const projectData: ProjectData = { + title: 'Title', + description: 'Description', + publicUrl: 'Public Url', + backstageUrl: 'Backstage Url', + publicInfo: 'Public Info', + backstageInfo: 'Backstage Info', + }; + const metadata = { + title: { row: 5, col: 10 }, + description: { row: 6, col: 10 }, + publicUrl: { row: 7, col: 10 }, + backstageUrl: { row: 9, col: 10 }, + publicInfo: { row: 10, col: 10 }, + backstageInfo: { row: 11, col: 10 }, + }; + const result = cellRequenstFromProjectData(projectData, 1234, metadata); + expect(result.updateCells.rows[0].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.title); + expect(result.updateCells.rows[1].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.description); + expect(result.updateCells.rows[2].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.publicUrl); + expect(result.updateCells.rows[4].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.backstageUrl); + expect(result.updateCells.rows[5].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.publicInfo); + expect(result.updateCells.rows[6].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.backstageInfo); + }); + + test('spacing in metadata', () => { + const projectData: ProjectData = { + title: 'Title', + description: 'Description', + publicUrl: 'Public Url', + backstageUrl: 'Backstage Url', + publicInfo: 'Public Info', + backstageInfo: 'Backstage Info', + }; + const metadata = { + title: { row: 0, col: 1 }, + description: { row: 1, col: 1 }, + publicUrl: { row: 2, col: 1 }, + backstageUrl: { row: 9, col: 1 }, + publicInfo: { row: 15, col: 1 }, + backstageInfo: { row: 50, col: 1 }, + }; + const result = cellRequenstFromProjectData(projectData, 1234, metadata); + expect(result.updateCells.rows[0].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.title); + expect(result.updateCells.rows[1].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.description); + expect(result.updateCells.rows[2].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.publicUrl); + expect(result.updateCells.rows[9].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.backstageUrl); + expect(result.updateCells.rows[15].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.publicInfo); + expect(result.updateCells.rows[50].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.backstageInfo); + }); + + test('sheet setup', () => { + const projectData: ProjectData = { + title: 'Title', + description: 'Description', + publicUrl: 'Public Url', + backstageUrl: 'Backstage Url', + publicInfo: 'Public Info', + backstageInfo: 'Backstage Info', + }; + const metadata = { + title: { row: 0, col: 10 }, + description: { row: 1, col: 10 }, + publicUrl: { row: 2, col: 10 }, + backstageUrl: { row: 3, col: 10 }, + publicInfo: { row: 4, col: 10 }, + backstageInfo: { row: 5, col: 10 }, + }; + const result = cellRequenstFromProjectData(projectData, 1234, metadata); + expect(result.updateCells.start.rowIndex).toStrictEqual(0); + expect(result.updateCells.start.columnIndex).toStrictEqual(11); + expect(result.updateCells.fields).toStrictEqual('userEnteredValue'); + }); +}); diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index 6135284bb..4a4ec8390 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -38,6 +38,7 @@ import { } from './parserFunctions.js'; import { parseExcelDate } from './time.js'; import { configService } from '../services/ConfigService.js'; +import { coerceBoolean } from './coerceType.js'; export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; export const JSON_MIME = 'application/json'; @@ -49,6 +50,8 @@ export const JSON_MIME = 'application/json'; * @returns {object} - parsed object */ export const parseExcel = (excelData: unknown[][], options?: Partial) => { + const projectMetadata = {}; + const rundownMetadata = {}; const importMap: ExcelImportMap = { ...defaultExcelImportMap, ...options }; const projectData: Partial = { title: '', @@ -107,146 +110,240 @@ export const parseExcel = (excelData: unknown[][], options?: Partial e.length > 0) - .forEach((row) => { - // these fields contain the data to its right - let projectTitleNext = false; - let projectDescriptionNext = false; - let publicUrlNext = false; - let publicInfoNext = false; - let backstageUrlNext = false; - let backstageInfoNext = false; + excelData.forEach((row, rowIndex) => { + if (row.length === 0) { + return; + } + // these fields contain the data to its right + let projectTitleNext = false; + let projectDescriptionNext = false; + let publicUrlNext = false; + let publicInfoNext = false; + let backstageUrlNext = false; + let backstageInfoNext = false; - const event: Partial = {}; - const handlers = { - [importMap.projectName]: () => (projectTitleNext = true), - [importMap.projectDescription]: () => (projectDescriptionNext = true), - [importMap.publicUrl]: () => (publicUrlNext = true), - [importMap.publicInfo]: () => (publicInfoNext = true), - [importMap.backstageUrl]: () => (backstageUrlNext = true), - [importMap.backstageInfo]: () => (backstageInfoNext = true), + const event: Partial = {}; + const handlers = { + [importMap.projectName]: (row: number, col: number) => { + projectTitleNext = true; + projectMetadata['title'] = { row, col }; + }, + [importMap.projectDescription]: (row: number, col: number) => { + projectDescriptionNext = true; + projectMetadata['description'] = { row, col }; + }, + [importMap.publicUrl]: (row: number, col: number) => { + publicUrlNext = true; + projectMetadata['publicUrl'] = { row, col }; + }, + [importMap.publicInfo]: (row: number, col: number) => { + publicInfoNext = true; + projectMetadata['publicInfo'] = { row, col }; + }, + [importMap.backstageUrl]: (row: number, col: number) => { + backstageUrlNext = true; + projectMetadata['backstageUrl'] = { row, col }; + }, + [importMap.backstageInfo]: (row: number, col: number) => { + backstageInfoNext = true; + projectMetadata['backstageInfo'] = { row, col }; + }, - [importMap.timeStart]: (index: number) => (timeStartIndex = index), - [importMap.timeEnd]: (index: number) => (timeEndIndex = index), - [importMap.duration]: (index: number) => (durationIndex = index), + [importMap.timeStart]: (row: number, col: number) => { + timeStartIndex = col; + rundownMetadata['timeStart'] = { row, col }; + }, + [importMap.timeEnd]: (row: number, col: number) => { + timeEndIndex = col; + rundownMetadata['timeEnd'] = { row, col }; + }, + [importMap.duration]: (row: number, col: number) => { + durationIndex = col; + rundownMetadata['duration'] = { row, col }; + }, - [importMap.cue]: (index: number) => (cueIndex = index), - [importMap.title]: (index: number) => (titleIndex = index), - [importMap.presenter]: (index: number) => (presenterIndex = index), - [importMap.subtitle]: (index: number) => (subtitleIndex = index), - [importMap.isPublic]: (index: number) => (isPublicIndex = index), - [importMap.skip]: (index: number) => (skipIndex = index), - [importMap.note]: (index: number) => (notesIndex = index), - [importMap.colour]: (index: number) => (colourIndex = index), + [importMap.cue]: (row: number, col: number) => { + cueIndex = col; + rundownMetadata['cue'] = { row, col }; + }, + [importMap.title]: (row: number, col: number) => { + titleIndex = col; + rundownMetadata['title'] = { row, col }; + }, + [importMap.presenter]: (row: number, col: number) => { + presenterIndex = col; + rundownMetadata['presenter'] = { row, col }; + }, + [importMap.subtitle]: (row: number, col: number) => { + subtitleIndex = col; + rundownMetadata['subtitle'] = { row, col }; + }, + [importMap.isPublic]: (row: number, col: number) => { + isPublicIndex = col; + rundownMetadata['isPublic'] = { row, col }; + }, + [importMap.skip]: (row: number, col: number) => { + skipIndex = col; + rundownMetadata['skip'] = { row, col }; + }, + [importMap.note]: (row: number, col: number) => { + notesIndex = col; + rundownMetadata['note'] = { row, col }; + }, + [importMap.colour]: (row: number, col: number) => { + colourIndex = col; + rundownMetadata['colour'] = { row, col }; + }, - [importMap.endAction]: (index: number) => (endActionIndex = index), - [importMap.timerType]: (index: number) => (timerTypeIndex = index), + [importMap.endAction]: (row: number, col: number) => { + endActionIndex = col; + rundownMetadata['endAction'] = { row, col }; + }, + [importMap.timerType]: (row: number, col: number) => { + timerTypeIndex = col; + rundownMetadata['timerType'] = { row, col }; + }, + [importMap.timeWarning]: (row: number, col: number) => { + timeWarningIndex = col; + rundownMetadata['timeWarningIndex'] = { row, col }; + }, + [importMap.timeDanger]: (row: number, col: number) => { + timeDangerIndex = col; + rundownMetadata['timeDangerIndex'] = { row, col }; + }, - [importMap.user0]: (index: number) => (user0Index = index), - [importMap.user1]: (index: number) => (user1Index = index), - [importMap.user2]: (index: number) => (user2Index = index), - [importMap.user3]: (index: number) => (user3Index = index), - [importMap.user4]: (index: number) => (user4Index = index), - [importMap.user5]: (index: number) => (user5Index = index), - [importMap.user6]: (index: number) => (user6Index = index), - [importMap.user7]: (index: number) => (user7Index = index), - [importMap.user8]: (index: number) => (user8Index = index), - [importMap.user9]: (index: number) => (user9Index = index), - [importMap.timeWarning]: (index: number) => (timeWarningIndex = index), - [importMap.timeDanger]: (index: number) => (timeDangerIndex = index), - } as const; + [importMap.user0]: (row: number, col: number) => { + user0Index = col; + rundownMetadata['user0'] = { row, col }; + }, + [importMap.user1]: (row: number, col: number) => { + user1Index = col; + rundownMetadata['user1'] = { row, col }; + }, + [importMap.user2]: (row: number, col: number) => { + user2Index = col; + rundownMetadata['user2'] = { row, col }; + }, + [importMap.user3]: (row: number, col: number) => { + user3Index = col; + rundownMetadata['user3'] = { row, col }; + }, + [importMap.user4]: (row: number, col: number) => { + user4Index = col; + rundownMetadata['user4'] = { row, col }; + }, + [importMap.user5]: (row: number, col: number) => { + user5Index = col; + rundownMetadata['user5'] = { row, col }; + }, + [importMap.user6]: (row: number, col: number) => { + user6Index = col; + rundownMetadata['user6'] = { row, col }; + }, + [importMap.user7]: (row: number, col: number) => { + user7Index = col; + rundownMetadata['user7'] = { row, col }; + }, + [importMap.user8]: (row: number, col: number) => { + user8Index = col; + rundownMetadata['user8'] = { row, col }; + }, + [importMap.user9]: (row: number, col: number) => { + user9Index = col; + rundownMetadata['user9'] = { row, col }; + }, + } as const; - row.forEach((column, j) => { - // 1. we check if we have set a flag for a known field - if (projectTitleNext) { - projectData.title = makeString(column, ''); - projectTitleNext = false; - } else if (projectDescriptionNext) { - projectData.description = makeString(column, ''); - projectDescriptionNext = false; - } else if (publicUrlNext) { - projectData.publicUrl = makeString(column, ''); - publicUrlNext = false; - } else if (publicInfoNext) { - projectData.publicInfo = makeString(column, ''); - publicInfoNext = false; - } else if (backstageUrlNext) { - projectData.backstageUrl = makeString(column, ''); - backstageUrlNext = false; - } else if (backstageInfoNext) { - projectData.backstageInfo = makeString(column, ''); - backstageInfoNext = false; - } else if (j === timeStartIndex) { - event.timeStart = parseExcelDate(column); - } else if (j === timeEndIndex) { - event.timeEnd = parseExcelDate(column); - } else if (j === durationIndex) { - event.duration = parseExcelDate(column); - } else if (j === titleIndex) { - event.title = makeString(column, ''); - } else if (j === cueIndex) { - event.cue = makeString(column, ''); - } else if (j === presenterIndex) { - event.presenter = makeString(column, ''); - } else if (j === subtitleIndex) { - event.subtitle = makeString(column, ''); - } else if (j === isPublicIndex) { - event.isPublic = Boolean(column); - } else if (j === skipIndex) { - event.skip = Boolean(column); - } else if (j === notesIndex) { - event.note = makeString(column, ''); - } else if (j === endActionIndex) { - event.endAction = validateEndAction(column); - } else if (j === timerTypeIndex) { - event.timerType = validateTimerType(column); - } else if (j === colourIndex) { - event.colour = makeString(column, ''); - } else if (j === user0Index) { - event.user0 = makeString(column, ''); - } else if (j === user1Index) { - event.user1 = makeString(column, ''); - } else if (j === user2Index) { - event.user2 = makeString(column, ''); - } else if (j === user3Index) { - event.user3 = makeString(column, ''); - } else if (j === user4Index) { - event.user4 = makeString(column, ''); - } else if (j === user5Index) { - event.user5 = makeString(column, ''); - } else if (j === user6Index) { - event.user6 = makeString(column, ''); - } else if (j === user7Index) { - event.user7 = makeString(column, ''); - } else if (j === user8Index) { - event.user8 = makeString(column, ''); - } else if (j === user9Index) { - event.user9 = makeString(column, ''); - } else if (j === timeWarningIndex) { - event.timeWarning = parseExcelDate(column); - } else if (j === timeDangerIndex) { - event.timeDanger = parseExcelDate(column); - } else { - // 2. if there is no flag, lets see if we know the field type - if (typeof column === 'string') { - const col = column.toLowerCase(); + row.forEach((column, j) => { + // 1. we check if we have set a flag for a known field + if (projectTitleNext) { + projectData.title = makeString(column, ''); + projectTitleNext = false; + } else if (projectDescriptionNext) { + projectData.description = makeString(column, ''); + projectDescriptionNext = false; + } else if (publicUrlNext) { + projectData.publicUrl = makeString(column, ''); + publicUrlNext = false; + } else if (publicInfoNext) { + projectData.publicInfo = makeString(column, ''); + publicInfoNext = false; + } else if (backstageUrlNext) { + projectData.backstageUrl = makeString(column, ''); + backstageUrlNext = false; + } else if (backstageInfoNext) { + projectData.backstageInfo = makeString(column, ''); + backstageInfoNext = false; + } else if (j === timeStartIndex) { + event.timeStart = parseExcelDate(column); + } else if (j === timeEndIndex) { + event.timeEnd = parseExcelDate(column); + } else if (j === durationIndex) { + event.duration = parseExcelDate(column); + } else if (j === titleIndex) { + event.title = makeString(column, ''); + } else if (j === cueIndex) { + event.cue = makeString(column, ''); + } else if (j === presenterIndex) { + event.presenter = makeString(column, ''); + } else if (j === subtitleIndex) { + event.subtitle = makeString(column, ''); + } else if (j === isPublicIndex) { + event.isPublic = column == 'x' ? true : coerceBoolean(column); + } else if (j === skipIndex) { + event.skip = column == 'x' ? true : coerceBoolean(column); + } else if (j === notesIndex) { + event.note = makeString(column, ''); + } else if (j === endActionIndex) { + event.endAction = validateEndAction(column); + } else if (j === timerTypeIndex) { + event.timerType = validateTimerType(column); + } else if (j === timeWarningIndex) { + event.timeWarning = parseExcelDate(column); + } else if (j === timeDangerIndex) { + event.timeDanger = parseExcelDate(column); + } else if (j === colourIndex) { + event.colour = makeString(column, ''); + } else if (j === user0Index) { + event.user0 = makeString(column, ''); + } else if (j === user1Index) { + event.user1 = makeString(column, ''); + } else if (j === user2Index) { + event.user2 = makeString(column, ''); + } else if (j === user3Index) { + event.user3 = makeString(column, ''); + } else if (j === user4Index) { + event.user4 = makeString(column, ''); + } else if (j === user5Index) { + event.user5 = makeString(column, ''); + } else if (j === user6Index) { + event.user6 = makeString(column, ''); + } else if (j === user7Index) { + event.user7 = makeString(column, ''); + } else if (j === user8Index) { + event.user8 = makeString(column, ''); + } else if (j === user9Index) { + event.user9 = makeString(column, ''); + } else { + // 2. if there is no flag, lets see if we know the field type + if (typeof column === 'string') { + const col = column.toLowerCase(); - if (handlers[col]) { - handlers[col](j); - } - // else. we don't know how to handle this column - // just ignore it + if (handlers[col]) { + handlers[col](rowIndex, j); } + // else. we don't know how to handle this column + // just ignore it } - }); - - if (Object.keys(event).length > 0) { - // if any data was found, push to array - rundown.push({ ...event, type: SupportedEvent.Event } as OntimeEvent); } }); + if (Object.keys(event).length > 0) { + // if any data was found, push to array + rundown.push({ ...event, type: SupportedEvent.Event } as OntimeEvent); + } + }); + return { rundown, project: projectData, @@ -255,6 +352,8 @@ export const parseExcel = (excelData: unknown[][], options?: Partial { } } return { ...newUserFields }; -}; +}; \ No newline at end of file diff --git a/apps/server/src/utils/sheetUtils.ts b/apps/server/src/utils/sheetUtils.ts new file mode 100644 index 000000000..190ef348b --- /dev/null +++ b/apps/server/src/utils/sheetUtils.ts @@ -0,0 +1,165 @@ +import { sheets_v4 } from '@googleapis/sheets'; +import { millisToString } from 'ontime-utils'; +import { OntimeRundownEntry, ProjectData, isOntimeEvent } from 'ontime-types'; + +/** + * + * @param {number} row - The row number of the cell reference. Row 1 is row number 0. + * @param {number} column - The column number of the cell reference. A is column number 0. + * @returns {string} - Returns a cell reference as a string using A1 Notation + * @author https://www.labnol.org/convert-column-a1-notation-210601 + * @example + * + * getA1Notation(2, 4) returns "E3" + * getA1Notation(99, 26) returns "AA100" + * + */ +export function getA1Notation(row: number, column: number): string { + if (row < 0 || column < 0) { + throw new Error('Index can not be less than 0'); + } + const a1Notation = [`${row + 1}`]; + const totalAlphabets = 'Z'.charCodeAt(0) - 'A'.charCodeAt(0) + 1; + let block = column; + while (block >= 0) { + a1Notation.unshift(String.fromCharCode((block % totalAlphabets) + 'A'.charCodeAt(0))); + block = Math.floor(block / totalAlphabets) - 1; + } + return a1Notation.join(''); +} + +/** + * @description - creates updateCells request from ontime event + * @param {OntimeRundownEntry} event + * @param {number} index - index of the event + * @param {number} worksheetId + * @param {any} metadata - object with all the cell positions of the title of each attribute + * @returns {sheets_v4.Schema} - list of update requests + */ +export function cellRequestFromEvent( + event: OntimeRundownEntry, + index: number, + worksheetId: number, + metadata, +): sheets_v4.Schema$Request { + const returnRows: sheets_v4.Schema$CellData[] = []; + const tmp = Object.entries(metadata) + .filter(([_, value]) => value !== undefined) + .sort(([_a, a], [_b, b]) => a['col'] - b['col']) as [string, { col: number; row: number }][]; + + const titleCol = tmp[0][1].col; + + for (const [index, e] of tmp.entries()) { + if (index !== 0) { + const prevCol = tmp[index - 1][1].col; + const thisCol = e[1].col; + const diff = thisCol - prevCol; + if (diff > 1) { + const fillArr = new Array<(typeof tmp)[0]>(1).fill(['blank', { row: e[1].row, col: prevCol + 1 }]); + tmp.splice(index, 0, ...fillArr); + } + } + } + + tmp.forEach(([key, _]) => { + if (isOntimeEvent(event)) { + if (key === 'blank') { + returnRows.push({}); + } else if (key === 'colour') { + returnRows.push({ + userEnteredValue: { stringValue: event.colour }, + }); + } else if (typeof event[key] === 'number') { + returnRows.push({ + userEnteredValue: { stringValue: millisToString(event[key], true) }, + }); + } else if (typeof event[key] === 'string') { + returnRows.push({ + userEnteredValue: { stringValue: event[key] }, + }); + } else if (typeof event[key] === 'boolean') { + returnRows.push({ + userEnteredValue: { stringValue: event[key] ? 'x' : '' }, + }); + } else { + returnRows.push({}); + } + } + }); + return { + updateCells: { + start: { + sheetId: worksheetId, + rowIndex: index + tmp[0][1]['row'] + 1, + columnIndex: titleCol, + }, + fields: 'userEnteredValue', + rows: [ + { + values: returnRows, + }, + ], + }, + }; +} + +/** + * @description - creates updateCells request from ontime event + * @param {ProjectData} projectData + * @param {number} worksheetId + * @param {any} metadata - object with all the cell positions of the title of each attribute + * @returns {sheets_v4.Schema} - list of update requests + */ +export function cellRequenstFromProjectData( + projectData: ProjectData, + worksheetId: number, + metadata, +): sheets_v4.Schema$Request { + const returnRows: sheets_v4.Schema$RowData[] = []; + const tmp = Object.entries(metadata) + .filter(([_, value]) => value !== undefined) + .sort(([_a, a], [_b, b]) => a['col'] - b['col']) as [string, { col: number; row: number }][]; + + const minRow = Object.values(metadata).reduce( + (accumulator: number, val) => Math.min(accumulator, val['row']), + Number.MAX_VALUE, + ) as number; + const minCol = tmp[0][1].col + 1; + + for (const [index, e] of tmp.entries()) { + if (index != 0) { + const prevRow = tmp[index - 1][1].row; + const thisRow = e[1].row; + const diff = thisRow - prevRow; + if (diff > 1) { + const fillArr = new Array<(typeof tmp)[0]>(1).fill(['blank', { row: prevRow + 1, col: e[1].col }]); + tmp.splice(index, 0, ...fillArr); + } + } + } + tmp.forEach(([key, _]) => { + if (key == 'blank') { + returnRows.push({}); + } else { + returnRows.push({ + values: [ + { + userEnteredValue: { stringValue: projectData[key] }, + }, + ], + }); + } + }); + + return { + updateCells: { + start: { + sheetId: worksheetId, + rowIndex: minRow, + columnIndex: minCol, + }, + fields: 'userEnteredValue', + rows: returnRows, + }, + }; +} diff --git a/apps/server/src/utils/sheetsAuth.ts b/apps/server/src/utils/sheetsAuth.ts new file mode 100644 index 000000000..3b1319dcb --- /dev/null +++ b/apps/server/src/utils/sheetsAuth.ts @@ -0,0 +1,380 @@ +import { sheets, sheets_v4 } from '@googleapis/sheets'; +import { writeFile } from 'fs/promises'; +import { readFileSync } from 'fs'; +import { OAuth2Client } from 'google-auth-library'; +import http from 'http'; +import { DatabaseModel, LogOrigin } from 'ontime-types'; +import { join } from 'path'; +import { URL } from 'url'; +import { logger } from '../classes/Logger.js'; +import { DataProvider } from '../classes/data-provider/DataProvider.js'; +import { getAppDataPath } from '../setup.js'; +import { ensureDirectory } from './fileManagement.js'; +import { cellRequestFromEvent, cellRequenstFromProjectData, getA1Notation } from './sheetUtils.js'; +import { parseExcel } from './parser.js'; +import { parseProject, parseRundown, parseUserFields } from './parserFunctions.js'; +import { ExcelImportMap } from 'ontime-utils'; + +type ResponseOK = { + data: Partial; +}; + +class Sheet { + private static client: null | OAuth2Client = null; + private readonly scope = 'https://www.googleapis.com/auth/spreadsheets'; + private readonly sheetsFolder: string; + private readonly clientSecretFile: string; + private static clientSecret = null; + private static authUrl: null | string = null; + private authServerTimeout; + + private readonly requiredClientKeys = [ + 'client_id', + 'project_id', + 'auth_uri', + 'token_uri', + 'auth_provider_x509_cert_url', + 'client_secret', + 'redirect_uris', + ]; + + constructor() { + const appDataPath = getAppDataPath(); + if (appDataPath === '') { + throw new Error('Sheet: Could not resolve sheet folser'); + } + this.sheetsFolder = join(appDataPath, 'sheets'); + this.clientSecretFile = join(this.sheetsFolder, 'client_secret.json'); + ensureDirectory(this.sheetsFolder); + try { + const secrets = JSON.parse(readFileSync(this.clientSecretFile, 'utf-8')); + const isKeyMissing = this.requiredClientKeys.some((key) => !(key in secrets['installed'])); + if (!isKeyMissing) { + Sheet.clientSecret = secrets; + } + } catch (_) { + /* empty - it is ok thet there is no clientSecret */ + } + } + + /** + * @description SETP 1 - saves secrets object to appdata path as client_secret.json + * @param {object} secrets + * @throws + */ + public async saveClientSecrets(secrets: object) { + Sheet.client = null; + Sheet.authUrl = null; + Sheet.clientSecret = null; + + const isKeyMissing = this.requiredClientKeys.some((key) => !(key in secrets['installed'])); + if (isKeyMissing) { + throw new Error('Client file is missing some keys'); + } + + await writeFile(this.clientSecretFile, JSON.stringify(secrets), 'utf-8').catch((err) => { + throw new Error(`Unable to save client file to disk ${err}`); + }); + Sheet.clientSecret = secrets; + } + + /** + * @description SETP 1 - test that the saved object is pressent + */ + testClientSecret() { + return Sheet.clientSecret !== null; + } + + /** + * @description SETP 2 - create server to interact with th OAuth2 request + * @returns {Promise} - returns url path serve on success + * @throws + */ + async openAuthServer(): Promise { + //TODO: this only works on local networks + + // if the server is allready running retun it + if (Sheet.authUrl) { + clearTimeout(this.authServerTimeout); + this.authServerTimeout = setTimeout( + () => { + Sheet.authUrl = null; + server.unref; + }, + 2 * 60 * 1000, + ); + return Sheet.authUrl; + } + + // Check that Secret is valid + const keyFile = Sheet.clientSecret; + const keys = keyFile.installed || keyFile.web; + if (!keys.redirect_uris || keys.redirect_uris.length === 0) { + throw new Error('Sheet: Missing redirect URI'); + } + const redirectUri = new URL(keys.redirect_uris[0]); + if (redirectUri.hostname !== 'localhost') { + throw new Error('Sheet: Invalid redirect URI'); + } + + // create an oAuth client to authorize the API call + const client = new OAuth2Client({ + clientId: keys.client_id, + clientSecret: keys.client_secret, + }); + + // start the server that will recive the codes + const server = http.createServer(async (req, res) => { + try { + const serverUrl = new URL(req.url, 'http://localhost:3000'); + if (serverUrl.pathname !== redirectUri.pathname) { + res.end('Invalid callback URL'); + return; + } + const searchParams = serverUrl.searchParams; + if (searchParams.has('error')) { + res.end('Authorization rejected.'); + logger.info(LogOrigin.Server, `Sheet: ${searchParams.get('error')}`); + return; + } + if (!searchParams.has('code')) { + res.end('No authentication code provided.'); + logger.info(LogOrigin.Server, `Sheet: Cannot read authentication code`); + return; + } + const code = searchParams.get('code'); + const { tokens } = await client.getToken({ + code, + redirect_uri: redirectUri.toString(), + }); + client.credentials = tokens; + Sheet.client = client; + res.end('Authentication successful! Please close this tab and return to OnTime.'); + logger.info(LogOrigin.Server, `Sheet: Authentication successful`); + } catch (e) { + logger.error(LogOrigin.Server, `Sheet: ${e}`); + } finally { + server.close(); + } + }); + let listenPort = 3000; + if (keyFile.installed) { + // Use emphemeral port if not a web client + listenPort = 0; + } else if (redirectUri.port !== '') { + listenPort = Number(redirectUri.port); + } + //TODO: the server might not start correctly + server.listen(listenPort); + const address = server.address(); + if (typeof address !== 'string') { + redirectUri.port = String(address.port); + } + // open the browser to the authorize url to start the workflow + const authorizeUrl = client.generateAuthUrl({ + redirect_uri: redirectUri.toString(), + access_type: 'offline', + scope: this.scope, + }); + Sheet.authUrl = authorizeUrl; + this.authServerTimeout = setTimeout( + () => { + Sheet.authUrl = null; + server.unref(); + }, + 2 * 60 * 1000, + ); + return authorizeUrl; + } + + /** + * @description SETP 2 - test that the reciveed OAuth2 is still valid + * @throws + */ + async testAuthentication() { + if (Sheet.client) { + const ref = await Sheet.client.refreshAccessToken(); + if (ref.credentials.expiry_date > 10000) { + return true; + } else { + throw new Error('Unable to use access token'); + } + } else { + throw new Error('Unable to authenticate'); + } + } + + /** + * @description SETP 3 - test the given sheet id + * @throws + */ + async testSheetId(id: string) { + const spreadsheets = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.get({ + spreadsheetId: id, + includeGridData: false, + }); + if (spreadsheets.status != 200) { + throw new Error(spreadsheets.statusText); + } + return { worksheetOptions: spreadsheets.data.sheets.map((i) => i.properties.title) }; + } + + /** + * @description SETP 4 - test the given worksheet + * @throws + */ + async testWorksheet(id: string, worksheet: string) { + const spreadsheets = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.get({ + spreadsheetId: id, + includeGridData: false, + }); + if (spreadsheets.status != 200) { + throw new Error(spreadsheets.statusText); + } + const worksheetExist = spreadsheets.data.sheets.find((i) => i.properties.title === worksheet); + if (!worksheetExist) { + throw new Error('Unable to find worksheet'); + } + } + + /** + * test existence of sheet and worksheet + * @param {string} sheetId - https://docs.google.com/spreadsheets/d/[[spreadsheetId]]/edit#gid=0 + * @param {string} worksheet - the name of the worksheet containing ontime data + * @returns {Promise<{worksheetId: number, range: string}>} - id of worksheet and rage of worksheet + * @throws + */ + private async exist(sheetId: string, worksheet: string): Promise<{ worksheetId: number; range: string }> { + const spreadsheets = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.get({ + spreadsheetId: sheetId, + }); + + if (spreadsheets.status === 200) { + const ourWorksheetData = spreadsheets.data.sheets.find((n) => n.properties.title == worksheet); + if (ourWorksheetData !== undefined) { + const endCell = getA1Notation( + ourWorksheetData.properties.gridProperties.rowCount, + ourWorksheetData.properties.gridProperties.columnCount, + ); + return { worksheetId: ourWorksheetData.properties.sheetId, range: `${worksheet}!A1:${endCell}` }; + } + } else { + throw new Error('Uable to open spreadsheets'); + } + } + + /** + * @description SETP 5 - Upload the rundown to sheet + * @param {string} id - id of the sheet https://docs.google.com/spreadsheets/d/[[spreadsheetId]]/edit#gid=0 + * @param {ExcelImportMap} options + * @throws + */ + public async push(id: string, options: ExcelImportMap) { + const { worksheetId, range } = await this.exist(id, options.worksheet); + + const readResponse = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.values.get({ + spreadsheetId: id, + valueRenderOption: 'FORMATTED_VALUE', + majorDimension: 'ROWS', + range: range, + }); + if (readResponse.status === 200) { + const { rundownMetadata, projectMetadata } = parseExcel(readResponse.data.values, options); + const rundown = DataProvider.getRundown(); + const projectData = DataProvider.getProjectData(); + const titleRow = Object.values(rundownMetadata)[0]['row']; + + const updateRundown = Array(); + + // we can't delete the last unflozzen row so we create an empty one + updateRundown.push({ + insertDimension: { + inheritFromBefore: false, + range: { + dimension: 'ROWS', + startIndex: titleRow + 1, + endIndex: titleRow + 2, + sheetId: worksheetId, + }, + }, + }); + //and delete the rest + updateRundown.push({ + deleteDimension: { range: { dimension: 'ROWS', startIndex: titleRow + 2, sheetId: worksheetId } }, + }); + // insert the lenght of the rundown + updateRundown.push({ + insertDimension: { + inheritFromBefore: false, + range: { + dimension: 'ROWS', + startIndex: titleRow + 1, + endIndex: titleRow + rundown.length, + sheetId: worksheetId, + }, + }, + }); + + //update the corresponding row with event data + rundown.forEach((entry, index) => + updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, rundownMetadata)), + ); + + //update project data + updateRundown.push(cellRequenstFromProjectData(projectData, worksheetId, projectMetadata)); + + const writeResponse = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.batchUpdate({ + spreadsheetId: id, + requestBody: { + includeSpreadsheetInResponse: false, + responseRanges: [range], + requests: updateRundown, + }, + }); + + if (writeResponse.status === 200) { + logger.info(LogOrigin.Server, `Sheet: write: ${writeResponse.statusText}`); + } else { + throw new Error(`Sheet: write failed: ${writeResponse.statusText}`); + } + } else { + throw new Error(`Sheet: read failed: ${readResponse.statusText}`); + } + } + + /** + * @description STEP 5 - Downpload the rundown from sheet + * @param {string} id - id of the sheet https://docs.google.com/spreadsheets/d/[[spreadsheetId]]/edit#gid=0 + * @param {ExcelImportMap} options + * @returns {Promise>} + * @throws + */ + public async pull(id: string, options: ExcelImportMap): Promise> { + const { range } = await this.exist(id, options.worksheet); + + const res: Partial = {}; + + const googleResponse = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.values.get({ + spreadsheetId: id, + valueRenderOption: 'FORMATTED_VALUE', + majorDimension: 'ROWS', + range, + }); + + if (googleResponse.status === 200) { + res.data = {}; + const dataFromSheet = parseExcel(googleResponse.data.values, options); + res.data.rundown = parseRundown(dataFromSheet); + if (res.data.rundown.length < 1) { + throw new Error(`Sheet: Could not find data to import in the worksheet`); + } + res.data.project = parseProject(dataFromSheet); + res.data.userFields = parseUserFields(dataFromSheet); + return res; + } else { + throw new Error(`Sheet: read failed: ${googleResponse.statusText}`); + } + } +} + +export const sheet = new Sheet();