From c0fb865959d6baa811f7b55a14f39a30951c51e6 Mon Sep 17 00:00:00 2001 From: Alex Christoffer Rasmussen Date: Mon, 8 Jan 2024 15:51:58 +0100 Subject: [PATCH 1/5] feat: google sheets(#579) --------- Co-authored-by: Carlos Valente --- apps/client/src/common/api/apiConstants.ts | 1 + apps/client/src/common/api/ontimeApi.ts | 73 +++ apps/client/src/features/editors/Editor.tsx | 5 + apps/client/src/features/menu/MenuBar.tsx | 16 + .../modals/sheets-modal/SheetsModal.tsx | 469 ++++++++++++++++++ .../modals/sheets-modal/Step.module.scss | 42 ++ .../src/features/modals/sheets-modal/Step.tsx | 47 ++ apps/server/package.json | 2 + .../__test__/DataProvider.test.ts | 1 - .../src/controllers/ontimeController.ts | 121 +++++ .../controllers/ontimeController.validate.ts | 29 ++ apps/server/src/routes/ontimeRouter.ts | 30 ++ .../src/utils/__tests__/sheetUtils.test.ts | 433 ++++++++++++++++ apps/server/src/utils/parser.ts | 337 ++++++++----- apps/server/src/utils/parserFunctions.ts | 2 +- apps/server/src/utils/sheetUtils.ts | 165 ++++++ apps/server/src/utils/sheetsAuth.ts | 380 ++++++++++++++ pnpm-lock.yaml | 167 ++++++- 18 files changed, 2189 insertions(+), 131 deletions(-) create mode 100644 apps/client/src/features/modals/sheets-modal/SheetsModal.tsx create mode 100644 apps/client/src/features/modals/sheets-modal/Step.module.scss create mode 100644 apps/client/src/features/modals/sheets-modal/Step.tsx create mode 100644 apps/server/src/utils/__tests__/sheetUtils.test.ts create mode 100644 apps/server/src/utils/sheetUtils.ts create mode 100644 apps/server/src/utils/sheetsAuth.ts diff --git a/apps/client/src/common/api/apiConstants.ts b/apps/client/src/common/api/apiConstants.ts index 4ad3a12f3..778e848a9 100644 --- a/apps/client/src/common/api/apiConstants.ts +++ b/apps/client/src/common/api/apiConstants.ts @@ -9,6 +9,7 @@ export const HTTP_SETTINGS = ['httpSettings']; export const APP_SETTINGS = ['appSettings']; export const VIEW_SETTINGS = ['viewSettings']; export const RUNTIME = ['runtimeStore']; +export const SHEET_STATE = ['sheetState']; const location = window.location; const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws'; diff --git a/apps/client/src/common/api/ontimeApi.ts b/apps/client/src/common/api/ontimeApi.ts index 5c45a0b2e..2d2d20696 100644 --- a/apps/client/src/common/api/ontimeApi.ts +++ b/apps/client/src/common/api/ontimeApi.ts @@ -245,3 +245,76 @@ export async function getLatestVersion(): Promise { export async function postNew(initialData: Partial) { return axios.post(`${ontimeURL}/new`, initialData); } + +/** + * @description STEP 1 + */ +export const uploadSheetClientFile = async (file: File) => { + const formData = new FormData(); + formData.append('userFile', file); + const res = await axios + .post(`${ontimeURL}/sheet/clientsecret`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + .then((response) => response.data.id); + return res; +}; + +/** + * @description STEP 1 test + */ +export const getClientSecrect = async () => { + const response = await axios.get(`${ontimeURL}/sheet/clientsecret`); + return response.data; +}; + +/** + * @description STEP 2 + */ +export const getSheetsAuthUrl = async () => { + const response = await axios.get(`${ontimeURL}/sheet/authentication/url`); + return response.data; +}; + +/** + * @description STEP 2 test + */ +export const getAuthentication = async () => { + const response = await axios.get(`${ontimeURL}/sheet/authentication`); + return response.data; +}; + +/** + * @description STEP 3 + * @returns worksheetOptions + */ +export const postId = async (id: string) => { + const response = await axios.post(`${ontimeURL}/sheet/id`, { id }); + return response.data; +}; + +/** + * @description STEP 4 + */ +export const postWorksheet = async (id: string, worksheet: string) => { + const response = await axios.post(`${ontimeURL}/sheet/worksheet`, { id, worksheet }); + return response.data; +}; + +/** + * @description STEP 5 + */ +export const postPreviewSheet = async (id: string, options: ExcelImportMap) => { + const response = await axios.post(`${ontimeURL}/sheet/pull`, { id, options }); + return response.data.data; +}; + +/** + * @description STEP 5 + */ +export const postPushSheet = async (id: string, options: ExcelImportMap) => { + const response = await axios.post(`${ontimeURL}/sheet-push`, { id, options }); + return response.data.data; +}; diff --git a/apps/client/src/features/editors/Editor.tsx b/apps/client/src/features/editors/Editor.tsx index b195d7d6b..cae0f6420 100644 --- a/apps/client/src/features/editors/Editor.tsx +++ b/apps/client/src/features/editors/Editor.tsx @@ -5,6 +5,7 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary' import MenuBar from '../menu/MenuBar'; import AboutModal from '../modals/about-modal/AboutModal'; import QuickStart from '../modals/quick-start/QuickStart'; +import SheetsModal from '../modals/sheets-modal/SheetsModal'; import UploadModal from '../modals/upload-modal/UploadModal'; import styles from './Editor.module.scss'; @@ -28,6 +29,7 @@ export default function Editor() { } = useDisclosure(); const { isOpen: isAboutModalOpen, onOpen: onAboutModalOpen, onClose: onAboutModalClose } = useDisclosure(); const { isOpen: isQuickStartOpen, onOpen: onQuickStartOpen, onClose: onQuickStartClose } = useDisclosure(); + const { isOpen: isSheetsOpen, onOpen: onSheetsOpen, onClose: onSheetsClose } = useDisclosure(); // Set window title useEffect(() => { @@ -42,6 +44,7 @@ export default function Editor() { +
@@ -58,6 +61,8 @@ export default function Editor() { onAboutOpen={onAboutModalOpen} isQuickStartOpen={isQuickStartOpen} onQuickStartOpen={onQuickStartOpen} + isSheetsOpen={isSheetsOpen} + onSheetsOpen={onSheetsOpen} />
diff --git a/apps/client/src/features/menu/MenuBar.tsx b/apps/client/src/features/menu/MenuBar.tsx index 5281bc24d..52025b0bd 100644 --- a/apps/client/src/features/menu/MenuBar.tsx +++ b/apps/client/src/features/menu/MenuBar.tsx @@ -1,5 +1,7 @@ import { memo, useCallback, useEffect, useState } from 'react'; import { VStack } from '@chakra-ui/react'; +import { IoCloud } from '@react-icons/all-files/io5/IoCloud'; +import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline'; import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand'; import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle'; import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline'; @@ -31,6 +33,8 @@ interface MenuBarProps { onAboutOpen: () => void; isQuickStartOpen: boolean; onQuickStartOpen: () => void; + isSheetsOpen: boolean; + onSheetsOpen: () => void; } const buttonStyle = { @@ -58,6 +62,8 @@ const MenuBar = (props: MenuBarProps) => { onAboutOpen, isQuickStartOpen, onQuickStartOpen, + isSheetsOpen, + onSheetsOpen, } = props; const { isElectron, sendToElectron } = useElectronEvent(); @@ -174,6 +180,16 @@ const MenuBar = (props: MenuBarProps) => { />
+ : } + 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/server/package.json b/apps/server/package.json index ffc0e12c8..e508fb37e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -5,6 +5,7 @@ "version": "2.24.8", "exports": "./src/index.js", "dependencies": { + "@googleapis/sheets": "^5.0.5", "body-parser": "^1.20.0", "cors": "^2.8.5", "dotenv": "^16.0.1", @@ -13,6 +14,7 @@ "express-static-gzip": "^2.1.7", "express-validator": "^6.14.2", "got": "^14.0.0", + "google-auth-library": "^9.2.0", "lowdb": "^5.0.5", "multer": "^1.4.5-lts.1", "node-osc": "^9.0.2", 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/controllers/ontimeController.ts b/apps/server/src/controllers/ontimeController.ts index 177d1bf5a..eade30844 100644 --- a/apps/server/src/controllers/ontimeController.ts +++ b/apps/server/src/controllers/ontimeController.ts @@ -20,6 +20,8 @@ import { runtimeCacheStore } from '../stores/cachingStore.js'; import { delayedRundownCacheKey } from '../services/rundown-service/delayedRundown.utils.js'; import { integrationService } from '../services/integration-service/IntegrationService.js'; +import { sheet } from '../utils/sheetsAuth.js'; + // Create controller for GET request to '/ontime/poll' // Returns data for current state export const poll = async (req, res) => { @@ -455,3 +457,122 @@ export const postNew: RequestHandler = async (req, res) => { res.status(400).send({ message: error.toString() }); } }; + +//SHEET Functions +/** + * @description SETP-1 POST Client Secrect + * @returns parsed result + */ +export async function uploadSheetClientFile(req, res) { + if (!req.file.path) { + res.status(400).send({ message: 'File not found' }); + return; + } + try { + const client = JSON.parse(fs.readFileSync(req.file.path as string, 'utf-8')); + await sheet.saveClientSecrets(client); + res.status(200).send('OK'); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } + fs.unlink(req.file.path, (err) => { + if (err) logger.error(LogOrigin.Server, err.message); + }); +} + +/** + * @description SETP-1 GET Client Secrect status + */ +export const getClientSecrect = async (req, res) => { + try { + const clientSecrectExists = await sheet.testClientSecret(); + if (clientSecrectExists) { + res.status(200).send(); + } else { + res.status(500).send({ message: 'The Client ID does not exist' }); + } + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +}; + +/** + * @description SETP-2 GET sheet authentication url + */ +export async function getAuthenticationUrl(req, res) { + try { + const authUrl = await sheet.openAuthServer(); + res.status(200).send(authUrl); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +} + +/** + * @description SETP-2 GET sheet authentication status + */ +export const getAuthentication = async (req, res) => { + try { + await sheet.testAuthentication(); + res.status(200).send(); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +}; + +/** + * @description SETP-3 POST sheet id + * @returns list of worksheets + */ +export const postId = async (req, res) => { + try { + const { id } = req.body; + if (id.lenght < 40) { + res.status(400).send({ message: 'ID is usualy 44 characters long' }); + } + const state = await sheet.testSheetId(id); + res.status(200).send(state); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +}; + +/** + * @description SETP-4 POST worksheet + */ +export const postWorksheet = async (req, res) => { + try { + const { worksheet, id } = req.body; + const state = await sheet.testWorksheet(worksheet, id); + res.status(200).send(state); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +}; + +/** + * @description STEP-5 POST download undown to sheet + * @returns parsed result + */ +export async function pullSheet(req, res) { + try { + const { id, options } = req.body; + const data = await sheet.pull(id, options); + res.status(200).send(data); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +} + +/** + * @description STEP-5 POST upload rundown to sheet + */ +export async function pushSheet(req, res) { + try { + const { id, options } = req.body; + await sheet.push(id, options); + res.status(200).send(); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +} diff --git a/apps/server/src/controllers/ontimeController.validate.ts b/apps/server/src/controllers/ontimeController.validate.ts index be0d541eb..809ce9cb7 100644 --- a/apps/server/src/controllers/ontimeController.validate.ts +++ b/apps/server/src/controllers/ontimeController.validate.ts @@ -152,3 +152,32 @@ export const validatePatchProjectFile = [ next(); }, ]; + +export const validateSheetid = [ + body('id').exists().isString(), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; + +export const validateWorksheet = [ + body('id').exists().isString(), + body('worksheet').exists().isString(), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; + +export const validateSheetOptions = [ + body('id').exists().isString(), + // body('options').exists().isObject(), TODO: + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; diff --git a/apps/server/src/routes/ontimeRouter.ts b/apps/server/src/routes/ontimeRouter.ts index 080c7a0ef..ec05780d6 100644 --- a/apps/server/src/routes/ontimeRouter.ts +++ b/apps/server/src/routes/ontimeRouter.ts @@ -21,6 +21,13 @@ import { postViewSettings, previewExcel, postHTTP, + getAuthenticationUrl, + uploadSheetClientFile as uploadClientSecret, + pullSheet, + pushSheet, + postId, + getAuthentication, + getClientSecrect as getClientSecret, } from '../controllers/ontimeController.js'; import { @@ -32,6 +39,9 @@ import { viewValidator, validateHTTP, validateOscSubscription, + validateSheetid, + validateWorksheet, + validateSheetOptions, } from '../controllers/ontimeController.validate.js'; import { projectSanitiser } from '../controllers/projectController.validate.js'; @@ -96,3 +106,23 @@ router.post('/http', validateHTTP, postHTTP); // create route between controller and '/ontime/new' endpoint router.post('/new', projectSanitiser, postNew); + +//SETP-1 +router.post('/sheet/clientsecret', uploadFile, uploadClientSecret); +router.get('/sheet/clientsecret', uploadFile, getClientSecret); + +//SETP-2 +router.get('/sheet/authentication/url', getAuthenticationUrl); +router.get('/sheet/authentication', getAuthentication); + +//STEP-3 +router.post('/sheet/id', validateSheetid, postId); + +//STEP-4 +router.post('/sheet/worksheet', validateWorksheet, postId); + +//STEP-5 download and generate preview +router.post('/sheet/pull', validateSheetOptions, pullSheet); + +//STEP-5 upload +router.post('/sheet-push', validateSheetOptions, pushSheet); 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 94431631a..77fdaf433 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -36,6 +36,7 @@ import { parseViewSettings, } from './parserFunctions.js'; import { parseExcelDate } from './time.js'; +import { coerceBoolean } from './coerceType.js'; export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; export const JSON_MIME = 'application/json'; @@ -47,6 +48,8 @@ export const JSON_MIME = 'application/json'; * @returns {object} - parsed object */ export const parseExcel = (excelData: unknown[][], options?: Partial) => { + const projectMetadata = {}; + const rundownMetadata = {}; const importMap: ExcelImportMap = { ...defaultExcelImportMap, ...options }; const projectData: Partial = { title: '', @@ -103,140 +106,228 @@ 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.user0]: (index: number) => (user0Index = index), - [importMap.user1]: (index: number) => (user1Index = index), - [importMap.user2]: (index: number) => (user2Index = index), - [importMap.user3]: (index: number) => (user3Index = index), - [importMap.user4]: (index: number) => (user4Index = index), - [importMap.user5]: (index: number) => (user5Index = index), - [importMap.user6]: (index: number) => (user6Index = index), - [importMap.user7]: (index: number) => (user7Index = index), - [importMap.user8]: (index: number) => (user8Index = index), - [importMap.user9]: (index: number) => (user9Index = index), - } as const; + [importMap.user0]: (row: number, col: number) => { + user0Index = col; + rundownMetadata['user0'] = { row, col }; + }, + [importMap.user1]: (row: number, col: number) => { + user1Index = col; + rundownMetadata['user1'] = { row, col }; + }, + [importMap.user2]: (row: number, col: number) => { + user2Index = col; + rundownMetadata['user2'] = { row, col }; + }, + [importMap.user3]: (row: number, col: number) => { + user3Index = col; + rundownMetadata['user3'] = { row, col }; + }, + [importMap.user4]: (row: number, col: number) => { + user4Index = col; + rundownMetadata['user4'] = { row, col }; + }, + [importMap.user5]: (row: number, col: number) => { + user5Index = col; + rundownMetadata['user5'] = { row, col }; + }, + [importMap.user6]: (row: number, col: number) => { + user6Index = col; + rundownMetadata['user6'] = { row, col }; + }, + [importMap.user7]: (row: number, col: number) => { + user7Index = col; + rundownMetadata['user7'] = { row, col }; + }, + [importMap.user8]: (row: number, col: number) => { + user8Index = col; + rundownMetadata['user8'] = { row, col }; + }, + [importMap.user9]: (row: number, col: number) => { + user9Index = col; + rundownMetadata['user9'] = { row, col }; + }, + } as const; - row.forEach((column, j) => { - // 1. we check if we have set a flag for a known field - if (projectTitleNext) { - projectData.title = makeString(column, ''); - projectTitleNext = false; - } else if (projectDescriptionNext) { - projectData.description = makeString(column, ''); - projectDescriptionNext = false; - } else if (publicUrlNext) { - projectData.publicUrl = makeString(column, ''); - publicUrlNext = false; - } else if (publicInfoNext) { - projectData.publicInfo = makeString(column, ''); - publicInfoNext = false; - } else if (backstageUrlNext) { - projectData.backstageUrl = makeString(column, ''); - backstageUrlNext = false; - } else if (backstageInfoNext) { - projectData.backstageInfo = makeString(column, ''); - backstageInfoNext = false; - } else if (j === timeStartIndex) { - event.timeStart = parseExcelDate(column); - } else if (j === timeEndIndex) { - event.timeEnd = parseExcelDate(column); - } else if (j === durationIndex) { - event.duration = parseExcelDate(column); - } else if (j === titleIndex) { - event.title = makeString(column, ''); - } else if (j === cueIndex) { - event.cue = makeString(column, ''); - } else if (j === presenterIndex) { - event.presenter = makeString(column, ''); - } else if (j === subtitleIndex) { - event.subtitle = makeString(column, ''); - } else if (j === isPublicIndex) { - event.isPublic = Boolean(column); - } else if (j === skipIndex) { - event.skip = Boolean(column); - } else if (j === notesIndex) { - event.note = makeString(column, ''); - } else if (j === endActionIndex) { - event.endAction = validateEndAction(column); - } else if (j === timerTypeIndex) { - event.timerType = validateTimerType(column); - } else if (j === colourIndex) { - event.colour = makeString(column, ''); - } else if (j === user0Index) { - event.user0 = makeString(column, ''); - } else if (j === user1Index) { - event.user1 = makeString(column, ''); - } else if (j === user2Index) { - event.user2 = makeString(column, ''); - } else if (j === user3Index) { - event.user3 = makeString(column, ''); - } else if (j === user4Index) { - event.user4 = makeString(column, ''); - } else if (j === user5Index) { - event.user5 = makeString(column, ''); - } else if (j === user6Index) { - event.user6 = makeString(column, ''); - } else if (j === user7Index) { - event.user7 = makeString(column, ''); - } else if (j === user8Index) { - event.user8 = makeString(column, ''); - } else if (j === user9Index) { - event.user9 = makeString(column, ''); - } else { - // 2. if there is no flag, lets see if we know the field type - if (typeof column === 'string') { - const col = column.toLowerCase(); + row.forEach((column, j) => { + // 1. we check if we have set a flag for a known field + if (projectTitleNext) { + projectData.title = makeString(column, ''); + projectTitleNext = false; + } else if (projectDescriptionNext) { + projectData.description = makeString(column, ''); + projectDescriptionNext = false; + } else if (publicUrlNext) { + projectData.publicUrl = makeString(column, ''); + publicUrlNext = false; + } else if (publicInfoNext) { + projectData.publicInfo = makeString(column, ''); + publicInfoNext = false; + } else if (backstageUrlNext) { + projectData.backstageUrl = makeString(column, ''); + backstageUrlNext = false; + } else if (backstageInfoNext) { + projectData.backstageInfo = makeString(column, ''); + backstageInfoNext = false; + } else if (j === timeStartIndex) { + event.timeStart = parseExcelDate(column); + } else if (j === timeEndIndex) { + event.timeEnd = parseExcelDate(column); + } else if (j === durationIndex) { + event.duration = parseExcelDate(column); + } else if (j === titleIndex) { + event.title = makeString(column, ''); + } else if (j === cueIndex) { + event.cue = makeString(column, ''); + } else if (j === presenterIndex) { + event.presenter = makeString(column, ''); + } else if (j === subtitleIndex) { + event.subtitle = makeString(column, ''); + } else if (j === isPublicIndex) { + event.isPublic = column == 'x' ? true : coerceBoolean(column); + } else if (j === skipIndex) { + event.skip = column == 'x' ? true : coerceBoolean(column); + } else if (j === notesIndex) { + event.note = makeString(column, ''); + } else if (j === endActionIndex) { + event.endAction = validateEndAction(column); + } else if (j === timerTypeIndex) { + event.timerType = validateTimerType(column); + } else if (j === colourIndex) { + event.colour = makeString(column, ''); + } else if (j === user0Index) { + event.user0 = makeString(column, ''); + } else if (j === user1Index) { + event.user1 = makeString(column, ''); + } else if (j === user2Index) { + event.user2 = makeString(column, ''); + } else if (j === user3Index) { + event.user3 = makeString(column, ''); + } else if (j === user4Index) { + event.user4 = makeString(column, ''); + } else if (j === user5Index) { + event.user5 = makeString(column, ''); + } else if (j === user6Index) { + event.user6 = makeString(column, ''); + } else if (j === user7Index) { + event.user7 = makeString(column, ''); + } else if (j === user8Index) { + event.user8 = makeString(column, ''); + } else if (j === user9Index) { + event.user9 = makeString(column, ''); + } else { + // 2. if there is no flag, lets see if we know the field type + if (typeof column === 'string') { + const col = column.toLowerCase(); - if (handlers[col]) { - handlers[col](j); - } - // else. we don't know how to handle this column - // just ignore it + if (handlers[col]) { + handlers[col](rowIndex, j); } + // else. we don't know how to handle this column + // just ignore it } - }); - - if (Object.keys(event).length > 0) { - // if any data was found, push to array - rundown.push({ ...event, type: SupportedEvent.Event } as OntimeEvent); } }); + if (Object.keys(event).length > 0) { + // if any data was found, push to array + rundown.push({ ...event, type: SupportedEvent.Event } as OntimeEvent); + } + }); + return { rundown, project: projectData, @@ -245,6 +336,8 @@ export const parseExcel = (excelData: unknown[][], options?: Partial { } } 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(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1977e673..76993103a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,6 +249,9 @@ importers: apps/server: dependencies: + '@googleapis/sheets': + specifier: ^5.0.5 + version: 5.0.5 body-parser: specifier: ^1.20.0 version: 1.20.1 @@ -270,6 +273,9 @@ importers: express-validator: specifier: ^6.14.2 version: 6.14.2 + google-auth-library: + specifier: ^9.2.0 + version: 9.2.0 got: specifier: ^14.0.0 version: 14.0.0 @@ -2228,6 +2234,16 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true + /@googleapis/sheets@5.0.5: + resolution: {integrity: sha512-XMoONmgAJm2jYeTYHX4054VcEkElxlgqmnHvt0wAurzEHoGJLdUHhTAJXGPLgSs4WVMPtgU8HLrmk7/U+Qlw7A==} + engines: {node: '>=12.0.0'} + dependencies: + googleapis-common: 7.0.1 + transitivePeerDependencies: + - encoding + - supports-color + dev: false + /@humanwhocodes/config-array@0.11.13: resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==} engines: {node: '>=10.10.0'} @@ -3535,6 +3551,15 @@ packages: - supports-color dev: true + /agent-base@7.1.0: + resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==} + engines: {node: '>= 14'} + dependencies: + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: false + /ajv-keywords@3.5.2(ajv@6.12.6): resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: @@ -3787,13 +3812,16 @@ packages: /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} requiresBuild: true - dev: true /big-integer@1.6.51: resolution: {integrity: sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==} engines: {node: '>=0.6'} dev: true + /bignumber.js@9.1.2: + resolution: {integrity: sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==} + dev: false + /binary-extensions@2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} @@ -3896,6 +3924,10 @@ packages: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} dev: true + /buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + dev: false + /buffer-equal@1.0.0: resolution: {integrity: sha512-tcBWO2Dl4e7Asr9hTGcpVrCe+F7DubpmqWCTbj4FHLmjqO2hIaC383acQubWtRJhdceqs5uBHs6Es+Sk//RKiQ==} engines: {node: '>=0.4.0'} @@ -4425,7 +4457,6 @@ packages: optional: true dependencies: ms: 2.1.2 - dev: true /decimal.js@10.4.3: resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} @@ -4654,6 +4685,12 @@ packages: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} dev: true + /ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + dependencies: + safe-buffer: 5.2.1 + dev: false + /ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} dev: false @@ -5300,6 +5337,10 @@ packages: - supports-color dev: false + /extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + dev: false + /extract-zip@2.0.1: resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} engines: {node: '>= 10.17.0'} @@ -5562,6 +5603,30 @@ packages: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} dev: true + /gaxios@6.1.1: + resolution: {integrity: sha512-bw8smrX+XlAoo9o1JAksBwX+hi/RG15J+NTSxmNPIclKC3ZVK6C2afwY8OSdRvOK0+ZLecUJYtj2MmjOt3Dm0w==} + engines: {node: '>=14'} + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.2 + is-stream: 2.0.1 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: false + + /gcp-metadata@6.1.0: + resolution: {integrity: sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==} + engines: {node: '>=14'} + dependencies: + gaxios: 6.1.1 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: false + /gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -5693,6 +5758,36 @@ packages: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} dev: true + /google-auth-library@9.2.0: + resolution: {integrity: sha512-1oV3p0JhNEhVbj26eF3FAJcv9MXXQt4S0wcvKZaDbl4oHq5V3UJoSbsGZGQNcjoCdhW4kDSwOs11wLlHog3fgQ==} + engines: {node: '>=14'} + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.1.1 + gcp-metadata: 6.1.0 + gtoken: 7.0.1 + jws: 4.0.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: false + + /googleapis-common@7.0.1: + resolution: {integrity: sha512-mgt5zsd7zj5t5QXvDanjWguMdHAcJmmDrF9RkInCecNsyV7S7YtGqm5v2IWONNID88osb7zmx5FtrAP12JfD0w==} + engines: {node: '>=14.0.0'} + dependencies: + extend: 3.0.2 + gaxios: 6.1.1 + google-auth-library: 9.2.0 + qs: 6.11.0 + url-template: 2.0.8 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + dev: false + /gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} dependencies: @@ -5745,6 +5840,17 @@ packages: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} dev: true + /gtoken@7.0.1: + resolution: {integrity: sha512-KcFVtoP1CVFtQu0aSk3AyAt2og66PFhZAlkUOuWKwzMLoulHXG5W5wE5xAnHb+yl3/wEFoqGW7/cDGMU8igDZQ==} + engines: {node: '>=14.0.0'} + dependencies: + gaxios: 6.1.1 + jws: 4.0.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: false + /has-bigints@1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} dev: true @@ -5857,6 +5963,16 @@ packages: - supports-color dev: true + /https-proxy-agent@7.0.2: + resolution: {integrity: sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==} + engines: {node: '>= 14'} + dependencies: + agent-base: 7.1.0 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: false + /human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -6135,7 +6251,6 @@ packages: /is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} - dev: true /is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} @@ -6340,6 +6455,12 @@ packages: hasBin: true dev: true + /json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + dependencies: + bignumber.js: 9.1.2 + dev: false + /json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -6392,6 +6513,21 @@ packages: object.assign: 4.1.4 dev: true + /jwa@2.0.0: + resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==} + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + dev: false + + /jws@4.0.0: + resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + dependencies: + jwa: 2.0.0 + safe-buffer: 5.2.1 + dev: false + /keyv@4.5.2: resolution: {integrity: sha512-5MHbFaKn8cNSmVW7BYnijeAVlE4cYA/SVkifVgrh7yotnfhKmjuXpDKjrABLnT0SfHWV21P8ow07OGfRrNDg8g==} dependencies: @@ -6744,7 +6880,6 @@ packages: /ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - dev: true /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -6801,6 +6936,18 @@ packages: whatwg-url: 5.0.0 dev: true + /node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + dependencies: + whatwg-url: 5.0.0 + dev: false + /node-osc@9.0.2: resolution: {integrity: sha512-q+VQL7DMWRL5+yvzRlWVig8BD9raotLs6onHU4e8MaFgxmYuIwcXhsvQeyUZFiKP6y/qGUXU6K0T99gVmISwmA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -8203,7 +8350,6 @@ packages: /tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - dev: true /tr46@3.0.0: resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} @@ -8507,6 +8653,10 @@ packages: requires-port: 1.0.0 dev: true + /url-template@2.0.8: + resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} + dev: false + /use-callback-ref@1.3.0(@types/react@18.0.26)(react@18.2.0): resolution: {integrity: sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==} engines: {node: '>=10'} @@ -8559,6 +8709,11 @@ packages: engines: {node: '>= 0.4.0'} dev: false + /uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + dev: false + /v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} dev: true @@ -8911,7 +9066,6 @@ packages: /webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - dev: true /webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} @@ -8957,7 +9111,6 @@ packages: dependencies: tr46: 0.0.3 webidl-conversions: 3.0.1 - dev: true /which-boxed-primitive@1.0.2: resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} From 92cefa1331610ded3413aadff6b327f1bb5c0c34 Mon Sep 17 00:00:00 2001 From: Alex Christoffer Rasmussen Date: Tue, 9 Jan 2024 16:01:17 +0100 Subject: [PATCH 2/5] refactor: errors from integrationcontroller (#700) * refactor: these functions don't throw, test the return success and then throw * refactor: no need to catch and re-throw --- .../src/controllers/integrationController.ts | 45 +++++++------------ 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/apps/server/src/controllers/integrationController.ts b/apps/server/src/controllers/integrationController.ts index e95acab7b..acefceb40 100644 --- a/apps/server/src/controllers/integrationController.ts +++ b/apps/server/src/controllers/integrationController.ts @@ -139,11 +139,10 @@ export function dispatchFromAdapter( throw new Error(`Event index not recognised or out of range ${eventIndex}`); } - try { - // Indexes in frontend are 1 based - PlaybackService.startByIndex(eventIndex - 1); - } catch (error) { - throw new Error(`Error loading event:: ${error}`); + // Indexes in frontend are 1 based + const success = PlaybackService.startByIndex(eventIndex - 1); + if (!success) { + throw new Error(`Event index not recognised or out of range ${eventIndex}`); } break; } @@ -194,11 +193,8 @@ export function dispatchFromAdapter( if (isNaN(time)) { throw new Error(`Time not recognised ${payload}`); } - try { - PlaybackService.addTime(time); - } catch (error) { - throw new Error(`Could not add time: ${error}`); - } + + PlaybackService.addTime(time); break; } //deprecated @@ -208,11 +204,7 @@ export function dispatchFromAdapter( throw new Error(`Delay time not recognised ${payload}`); } - try { - PlaybackService.setDelay(delayTime); - } catch (error) { - throw new Error(`Could not add delay: ${error}`); - } + PlaybackService.setDelay(delayTime); break; } case 'gotoindex': @@ -222,11 +214,10 @@ export function dispatchFromAdapter( throw new Error(`Event index not recognised or out of range ${eventIndex}`); } - try { - // Indexes in frontend are 1 based - PlaybackService.loadByIndex(eventIndex - 1); - } catch (error) { - throw new Error(`Event index not recognised or out of range ${error}`); + // Indexes in frontend are 1 based + const success = PlaybackService.loadByIndex(eventIndex - 1); + if (!success) { + throw new Error(`Event index not recognised or out of range ${eventIndex}`); } break; } @@ -236,10 +227,9 @@ export function dispatchFromAdapter( throw new Error(`Event ID not recognised: ${payload}`); } - try { - PlaybackService.loadById(payload.toString().toLowerCase()); - } catch (error) { - throw new Error(`OSC IN: error calling goto ${error}`); + const success = PlaybackService.loadById(payload.toString().toLowerCase()); + if (!success) { + throw new Error(`Event ID not found: ${payload}`); } break; } @@ -249,10 +239,9 @@ export function dispatchFromAdapter( throw new Error(`Event cue not recognised: ${payload}`); } - try { - PlaybackService.loadByCue(payload); - } catch (error) { - throw new Error(`OSC IN: error calling goto ${error}`); + const success = PlaybackService.loadByCue(payload); + if (!success) { + throw new Error(`Event cue not found: ${payload}`); } break; } From 9611722a6dafa03211eef74f7c5d3505a746c65c Mon Sep 17 00:00:00 2001 From: Alex Christoffer Rasmussen Date: Tue, 9 Jan 2024 16:42:50 +0100 Subject: [PATCH 3/5] fix: use background-color: transparent; for overlap indicators (#699) --- .../event-block/EventBlock.module.scss | 10 ++--- .../rundown/event-block/EventBlockInner.tsx | 39 +++++++++---------- 2 files changed, 22 insertions(+), 27 deletions(-) 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 && ( + +
+ + )} )} Date: Tue, 9 Jan 2024 22:57:06 +0100 Subject: [PATCH 4/5] Fix/external path (#702) * leave styles as is but move other external to `external` folder --- apps/server/src/app.ts | 14 ++++++++++++-- apps/server/src/config/config.js | 4 ++++ apps/server/src/modules/loadDemo.ts | 21 +++++++++++++++++++++ apps/server/src/modules/loadStyles.ts | 11 +++-------- apps/server/src/setup.ts | 18 +++++++++++++++++- 5 files changed, 57 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/modules/loadDemo.ts diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 98a90b0e6..1264fcc94 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -8,7 +8,14 @@ import cors from 'cors'; // import utils import { join, resolve } from 'path'; -import { currentDirectory, environment, externalsStartDirectory, isProduction, resolvedPath } from './setup.js'; +import { + currentDirectory, + environment, + isProduction, + resolveExternalsDirectory, + resolveStylesDirectory, + resolvedPath, +} from './setup.js'; import { ONTIME_VERSION } from './ONTIME_VERSION.js'; // Import Routes @@ -35,6 +42,7 @@ import { eventStore, getInitialPayload } from './stores/EventStore.js'; import { PlaybackService } from './services/PlaybackService.js'; import { RestorePoint, restoreService } from './services/RestoreService.js'; import { messageService } from './services/message-service/MessageService.js'; +import { populateDemo } from './modules/loadDemo.js'; console.log(`Starting Ontime version ${ONTIME_VERSION}`); @@ -64,7 +72,8 @@ app.use('/ontime', ontimeRouter); app.use('/playback', playbackRouter); // serve static - css -app.use('/external', express.static(externalsStartDirectory)); +app.use('/external/styles', express.static(resolveStylesDirectory)); +app.use('/external/', express.static(resolveExternalsDirectory)); app.use('/external', (req, res) => { res.status(404).send(`${req.originalUrl} not found`); }); @@ -129,6 +138,7 @@ export const initAssets = async () => { checkStart(OntimeStartOrder.InitAssets); await dbLoadingProcess; populateStyles(); + populateDemo(); }; /** 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/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/setup.ts b/apps/server/src/setup.ts index efd83524e..ce9c58176 100644 --- a/apps/server/src/setup.ts +++ b/apps/server/src/setup.ts @@ -68,7 +68,8 @@ export const currentDirectory = dirname(__dirname); const testDbStartDirectory = isTest ? '../' : getAppDataPath(); export const externalsStartDirectory = isProduction ? getAppDataPath() : join(currentDirectory, 'external'); - +//TODO: we only need one when they are all in the same folder +export const resolveExternalsDirectory = join(isProduction ? getAppDataPath() : currentDirectory, 'external'); // path to public db export const resolveDbDirectory = join( testDbStartDirectory, @@ -80,11 +81,26 @@ export const pathToStartDb = isTest ? join(currentDirectory, '../', config.database.testdb, config.database.filename) : join(currentDirectory, '/preloaded-db/', config.database.filename); +//TODO: move all static files to the external directory // path to public styles export const resolveStylesDirectory = join(externalsStartDirectory, config.styles.directory); export const resolveStylesPath = join(resolveStylesDirectory, config.styles.filename); export const pathToStartStyles = join(currentDirectory, '/external/styles/', config.styles.filename); +// path to public demo +export const resolveDemoDirectory = join( + externalsStartDirectory, + isProduction ? '/external/' : '', //move to external folde in production + config.demo.directory, +); +export const resolveDemoPath = config.demo.filename.map((file) => { + return join(resolveDemoDirectory, file); +}); + +export const pathToStartDemo = config.demo.filename.map((file) => { + return join(currentDirectory, '/external/demo/', file); +}); + // path to restore file export const resolveRestoreFile = join(getAppDataPath(), config.restoreFile); From 71092afc02ee93d58df5589cc0c84fa45290ad4f Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Wed, 10 Jan 2024 20:50:19 +0100 Subject: [PATCH 5/5] ontime release (#694) * chore: version bump --------- Co-authored-by: arc-alex --- DEVELOPMENT.md | 1 + apps/client/package.json | 2 +- apps/electron/package.json | 2 +- apps/server/package.json | 2 +- package.json | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index c2b3f4221..0af5f3fd0 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/package.json b/apps/client/package.json index cfa23b605..609f3028f 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,6 +1,6 @@ { "name": "ontime-ui", - "version": "2.24.8", + "version": "2.28.11", "private": true, "dependencies": { "@chakra-ui/react": "^2.7.0", diff --git a/apps/electron/package.json b/apps/electron/package.json index e7f4a5f83..3db48da19 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,6 +1,6 @@ { "name": "ontime", - "version": "2.24.8", + "version": "2.28.11", "author": "Carlos Valente", "description": "Time keeping for live events", "repository": "https://github.com/cpvalente/ontime", diff --git a/apps/server/package.json b/apps/server/package.json index e508fb37e..ea61565cc 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -2,7 +2,7 @@ "name": "ontime-server", "type": "module", "main": "src/index.ts", - "version": "2.24.8", + "version": "2.28.11", "exports": "./src/index.js", "dependencies": { "@googleapis/sheets": "^5.0.5", diff --git a/package.json b/package.json index a13a02259..3e3a441c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ontime", - "version": "2.24.8", + "version": "2.28.11", "description": "Time keeping for live events", "keywords": [ "lightdev",