Sheet use limited input device auth flow (#782)

* refactor: limited-input-device auth

* refactor: resolve sheet directory from setup

* refactor: extract sheet logic in backend

* refactor: simplify sheet integration

---------

Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
This commit is contained in:
Alex Christoffer Rasmussen
2024-02-24 11:58:50 +01:00
committed by GitHub
parent fc5338903b
commit 474f1e2177
54 changed files with 1636 additions and 1144 deletions
+4 -2
View File
@@ -43,7 +43,7 @@ import { restoreService } from './services/RestoreService.js';
import { messageService } from './services/message-service/MessageService.js';
import { populateDemo } from './modules/loadDemo.js';
import { getState, updateRundownData } from './stores/runtimeState.js';
import { setRundown } from './services/rundown-service/RundownService.js';
import { initRundown } from './services/rundown-service/RundownService.js';
import { getPlayableEvents } from './services/rundown-service/rundownUtils.js';
import { generateCrashReport } from './utils/generateCrashReport.js';
@@ -183,7 +183,8 @@ export const startServer = async () => {
// initialise rundown service
const persistedRundown = DataProvider.getRundown();
setRundown(persistedRundown);
const persistedCustomFields = DataProvider.getCustomFields();
initRundown(persistedRundown, persistedCustomFields);
// TODO: do this on the init of the runtime service
updateRundownData(getPlayableEvents());
@@ -274,6 +275,7 @@ export const shutdown = async (exitCode = 0) => {
await restoreService.clear();
}
// TODO: Clear token
expressServer?.close();
oscServer?.shutdown();
runtimeService.shutdown();
@@ -17,6 +17,7 @@ import {
import { data, db } from '../../modules/loadDb.js';
import { safeMerge } from './DataProvider.utils.js';
import { isProduction } from '../../setup.js';
export class DataProvider {
static getData() {
@@ -109,6 +110,9 @@ export class DataProvider {
}
static async persist() {
if (!isProduction) {
return;
}
await db.write();
}
@@ -42,8 +42,8 @@ describe('safeMerge', () => {
user9: 'existing user9',
},
customFields: {
lighting: { type: 'string', label: 'lighting' },
vfx: { type: 'string', label: 'vfx' },
lighting: { type: 'string', label: 'lighting', colour: 'red' },
vfx: { type: 'string', label: 'vfx', colour: 'blue' },
},
osc: {
portIn: 8888,
+3
View File
@@ -12,6 +12,9 @@ export const config = {
directory: 'demo',
filename: ['app.js', 'index.html', 'styles.css'],
},
sheets: {
directory: 'sheets',
},
restoreFile: 'ontime.restore',
};
+4 -131
View File
@@ -1,4 +1,3 @@
import { LogOrigin } from 'ontime-types';
import type {
Alias,
DatabaseModel,
@@ -32,14 +31,12 @@ import {
} from '../setup.js';
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
import { httpIntegration } from '../services/integration-service/HttpIntegration.js';
import { logger } from '../classes/Logger.js';
import { notifyChanges, setRundown } from '../services/rundown-service/RundownService.js';
import { getProjectFiles } from '../utils/getFileListFromFolder.js';
import { configService } from '../services/ConfigService.js';
import { deleteFile } from '../utils/parserUtils.js';
import { validateProjectFiles } from './ontimeController.validate.js';
import { dbModel } from '../models/dataModel.js';
import { sheet } from '../utils/sheetsAuth.js';
import { removeFileExtension } from '../utils/removeFileExtension.js';
import type { OntimeError } from '../utils/backend.types.js';
import { ensureJsonExtension } from '../utils/ensureJsonExtension.js';
@@ -95,7 +92,7 @@ export type ParsingOptions = {
/**
* parse an uploaded file and apply its parsed objects
* @param file
* @param req
* @param _req
* @param res
* @param [options]
* @returns {Promise<void>}
@@ -277,7 +274,6 @@ export const postSettings = async (req: Request, res: Response) => {
/**
* @description Get view Settings
* @method GET
*/
export const getViewSettings = async (_req: Request, res: Response) => {
const views = DataProvider.getViewSettings();
@@ -286,7 +282,6 @@ export const getViewSettings = async (_req: Request, res: Response) => {
/**
* @description Change view Settings
* @method POST
*/
export const postViewSettings = async (req: Request, res: Response) => {
if (failEmptyObjects(req.body, res)) {
@@ -410,7 +405,7 @@ export const dbUpload = async (req: Request, res: Response) => {
* uploads and parses an excel file
* @returns parsed result
*/
export async function previewExcel(req, res: Response) {
export async function previewExcel(req: Request, res: Response) {
if (!req.file) {
res.status(400).send({ message: 'File not found' });
return;
@@ -428,10 +423,10 @@ export async function previewExcel(req, res: Response) {
/**
* Retrieves and lists all project files from the uploads directory.
* @param req
* @param _req
* @param res
*/
export const listProjects: RequestHandler = async (_, res: Response<ProjectFileListResponse | ErrorResponse>) => {
export const listProjects: RequestHandler = async (_req, res: Response<ProjectFileListResponse | ErrorResponse>) => {
try {
const fileList = await getProjectFiles();
@@ -637,125 +632,3 @@ export const deleteProjectFile: RequestHandler = async (req: Request, res: Respo
res.status(500).send({ message: String(error) });
}
};
// SHEET Functions
/**
* @description SETP-1 POST Client Secrect
* @returns parsed result
*/
export async function uploadSheetClientFile(req, res: Response) {
if (!req.file.path) {
res.status(400).send({ message: 'File not found' });
return;
}
try {
const client = JSON.parse(fs.readFileSync(req.file.path as string, 'utf-8'));
await sheet.saveClientSecrets(client);
res.status(200).send('OK');
} catch (error) {
res.status(500).send({ message: String(error) });
}
fs.unlink(req.file.path, (err) => {
if (err) logger.error(LogOrigin.Server, err.message);
});
}
/**
* @description STEP-1 GET Client Secret status
*/
export const getClientSecret = async (req: Request, res: Response) => {
try {
// TODO: can we merge this with the previous?
const clientSecretExists = await sheet.testClientSecret();
if (clientSecretExists) {
res.status(200).send();
} else {
res.status(500).send({ message: 'The Client ID does not exist' });
}
} catch (error) {
res.status(500).send({ message: String(error) });
}
};
/**
* @description STEP-2 GET sheet authentication url
*/
export async function getAuthenticationUrl(_req: Request, res: Response) {
try {
const authUrl = await sheet.openAuthServer();
res.status(200).send(authUrl);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
/**
* @description STEP-2 GET sheet authentication status
*/
export const getAuthentication = async (_req: Request, res: Response) => {
try {
await sheet.testAuthentication();
res.status(200).send();
} catch (error) {
res.status(500).send({ message: String(error) });
}
};
/**
* @description STEP-3 POST sheet id
* @returns list of worksheets
*/
export const postId = async (req: Request, res: Response) => {
try {
const { sheetId } = req.body;
if (sheetId.length < 40) {
res.status(400).send({ message: 'ID is usually 44 characters long' });
}
const state = await sheet.testSheetId(sheetId);
res.status(200).send(state);
} catch (error) {
res.status(500).send({ message: String(error) });
}
};
/**
* @description STEP-4 POST worksheet
*/
export const postWorksheet = async (req: Request, res: Response) => {
try {
const { sheetId, worksheet } = req.body;
const state = await sheet.testWorksheet(sheetId, worksheet);
res.status(200).send(state);
} catch (error) {
res.status(500).send({ message: String(error) });
}
};
/**
* @description STEP-5 POST download rundown to sheet
* @returns parsed result
*/
export async function pullSheet(req: Request, res: Response) {
try {
const { sheetId, options } = req.body;
console.log('starting');
const data = await sheet.pull(sheetId, options);
console.log('finished');
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
/**
* @description STEP-5 POST upload rundown to sheet
*/
export async function pushSheet(req: Request, res: Response) {
try {
const { sheetId, options } = req.body;
await sheet.push(sheetId, options);
res.status(200).send();
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
@@ -191,28 +191,6 @@ export const validateProjectRename = [
},
];
/**
* @description Validates the filename for creating a project file.
*/
export const validateProjectCreate = [
body('filename')
.exists()
.withMessage('Filename is required')
.isString()
.withMessage('Filename must be a string')
.isLength({ min: 1, max: 255 })
.withMessage('Filename must be between 1 and 255 characters'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
},
];
/**
* @description Validates the existence of project files.
* @param {object} projectFiles
@@ -243,35 +221,3 @@ export const validateProjectFiles = (projectFiles: { filename?: string; newFilen
return errors;
};
export const validateSheetId = [
body('sheetId').exists().isString(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateWorksheet = [
body('sheetId').exists().isString(),
body('worksheet').exists().isString(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateSheetOptions = [
body('sheetId').exists().isString(),
// body('options').exists().isObject(), TODO:
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -5,7 +5,12 @@ import { CustomField, CustomFields, ProjectData } from 'ontime-types';
import { removeUndefined } from '../utils/parserUtils.js';
import { failEmptyObjects } from '../utils/routerUtils.js';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { createCustomField, editCustomField, removeCustomField } from '../utils/customFields.js';
import {
createCustomField,
editCustomField,
getCustomFields as getCustomFieldsFromCache,
removeCustomField,
} from '../services/rundown-service/rundownCache.js';
// Create controller for GET request to 'project'
export const getProject: RequestHandler = async (req, res) => {
@@ -36,15 +41,12 @@ export const postProject: RequestHandler = async (req, res) => {
};
export const getCustomFields: RequestHandler = async (_req: Request, res: Response<CustomFields>) => {
res.json(DataProvider.getCustomFields());
const customFields = getCustomFieldsFromCache();
res.json(customFields);
};
// Expects { label: <lable> type: 'string | ..' }
// Expects { label: <label> type: 'string | ..' }
export const postCustomField: RequestHandler = async (req: Request, res: Response) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const newField = req.body as CustomField;
const allFields = await createCustomField(newField);
@@ -54,21 +56,19 @@ export const postCustomField: RequestHandler = async (req: Request, res: Respons
}
};
// Expects { label: <oldLable>, field: { label: <newlable> type: 'string | ..' } }
// Expects { label: <oldLabel>, field: { label: <newLabel> type: 'string | ..' } }
export const putCustomField: RequestHandler = async (req: Request, res: Response) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const newFields = await editCustomField(req.body.label, req.body.field);
const oldLabel = req.params.label;
const { colour, type, label } = req.body;
const newFields = await editCustomField(oldLabel, { label, colour, type });
res.status(200).send(newFields);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
// Expects { label: <lable> }
// Expects { label: <label> }
export const deleteCustomField: RequestHandler = async (req: Request, res: Response) => {
try {
const fieldToDelete = req.params.label;
@@ -1,5 +1,7 @@
import { body, validationResult } from 'express-validator';
import { isAlphanumeric } from 'ontime-utils';
import { Request, Response, NextFunction } from 'express';
import { body, param, validationResult } from 'express-validator';
export const projectSanitiser = [
body('title').optional().isString().trim(),
@@ -18,8 +20,15 @@ export const projectSanitiser = [
];
export const validateCustomField = [
body('label').isString().trim(),
body('type').isString().trim(),
body('label')
.exists()
.isString()
.trim()
.custom((value) => {
return isAlphanumeric(value);
}),
body('type').exists().isString().trim(),
body('colour').exists().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
@@ -29,9 +38,10 @@ export const validateCustomField = [
];
export const validateEditCustomField = [
body('label').isString().trim(),
body('field.label').optional().isString().trim(),
body('field.type').optional().isString().trim(),
param('label').exists().isString().trim(),
body('label').exists().isString().trim(),
body('type').exists().isString().trim(),
body('colour').exists().isString().trim(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
@@ -40,9 +50,8 @@ export const validateEditCustomField = [
},
];
export const valdiateDeleteCustomField = [
body('label').isString(),
export const validateDeleteCustomField = [
param('label').exists().isString(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
@@ -0,0 +1,47 @@
import { defaultExcelImportMap } from 'ontime-utils';
import { body, param, validationResult } from 'express-validator';
import { NextFunction, Request, Response } from 'express';
export const validateRequestConnection = [
param('sheetId')
.exists()
.isString()
.isLength({
min: 40,
max: 100,
})
.withMessage('Sheet ID is usually 44 characters long'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const validateSheetOptions = [
param('sheetId').exists().isString(),
body('options')
.exists()
.isObject()
.custom((content) => {
// Check if the fileContent has the same keys as defaultExcelImportMap
const hasValidKeys = Object.keys(defaultExcelImportMap).every((key) => key in content);
// Check if all values in fileContent are strings
const hasValidValues = Object.values(content).every((value) => typeof value === 'string');
if (!hasValidKeys || !hasValidValues) {
throw new Error('Invalid file format');
}
return true;
}),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -0,0 +1,79 @@
/**
* This module encapsulates logic related to
* Google Sheets
*/
import { Request, Response } from 'express';
import { readFileSync } from 'fs';
import { deleteFile } from '../utils/parserUtils.js';
import {
revoke,
handleClientSecret,
handleInitialConnection,
hasAuth,
download,
upload,
} from '../services/sheet-service/SheetService.js';
export async function requestConnection(req: Request, res: Response) {
const { sheetId } = req.params;
const file = req.file.path;
try {
const client = readFileSync(file, 'utf-8');
const clientSecret = handleClientSecret(client);
const { verification_url, user_code } = await handleInitialConnection(clientSecret, sheetId);
res.status(200).send({ verification_url, user_code });
} catch (error) {
res.status(500).send({ message: String(error) });
}
// delete uploaded file after parsing
try {
deleteFile(file);
} catch (_error) {
/** we dont handle failure here */
}
}
export async function verifyAuthentication(_req: Request, res: Response) {
try {
const authenticated = hasAuth();
res.status(200).send(authenticated);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
export async function revokeAuthentication(_req: Request, res: Response) {
try {
const authenticated = revoke();
res.status(200).send(authenticated);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
export async function readFromSheet(req: Request, res: Response) {
try {
const { sheetId } = req.params;
const { options } = req.body;
const data = await download(sheetId, options);
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
export async function writeToSheet(req: Request, res: Response) {
try {
const { sheetId } = req.params;
const { options } = req.body;
await upload(sheetId, options);
res.status(200).send();
} catch (error) {
res.status(500).send({ message: String(error) });
}
}
@@ -37,6 +37,7 @@ export const event: Omit<OntimeEvent, 'id' | 'delay' | 'cue'> = {
revision: 0,
timeWarning: 120000,
timeDanger: 60000,
custom: {},
};
export const delay: Omit<OntimeDelay, 'id'> = {
+15 -28
View File
@@ -1,5 +1,5 @@
import express from 'express';
import { uploadFile } from '../utils/upload.js';
import { uploadClientSecret, uploadFile } from '../utils/upload.js';
import {
dbDownload,
dbUpload,
@@ -25,14 +25,6 @@ import {
renameProjectFile,
createProjectFile,
deleteProjectFile,
getAuthenticationUrl,
uploadSheetClientFile as uploadClientSecret,
pullSheet,
pushSheet,
postId,
getAuthentication,
getClientSecret as getClientSecret,
postWorksheet,
} from '../controllers/ontimeController.js';
import {
@@ -46,12 +38,17 @@ import {
validateProjectDuplicate,
validateLoadProjectFile,
validateProjectRename,
validateSheetId,
validateWorksheet,
validateSheetOptions,
} from '../controllers/ontimeController.validate.js';
import { projectSanitiser } from '../controllers/projectController.validate.js';
import { sanitizeProjectFilename } from '../utils/sanitizeProjectFilename.js';
import {
revokeAuthentication,
readFromSheet,
requestConnection,
verifyAuthentication,
writeToSheet,
} from '../controllers/sheetsController.js';
import { validateRequestConnection, validateSheetOptions } from '../controllers/sheetController.validate.js';
export const router = express.Router();
@@ -127,23 +124,13 @@ router.post('/project', projectSanitiser, createProjectFile);
// create route between controller and '/ontime/project/:filename' endpoint
router.delete('/project/:filename', sanitizeProjectFilename, deleteProjectFile);
// TODO: move the google sheet stuff into a separate file
// Google Sheet integration - Step 1
router.post('/sheet/clientsecret', uploadFile, uploadClientSecret);
router.get('/sheet/clientsecret', uploadFile, getClientSecret);
// create route between controller and '/sheet/:sheetId/connect' endpoint
router.post('/sheet/:sheetId/connect', uploadClientSecret, validateRequestConnection, requestConnection);
// Google Sheet integration - Step 2
router.get('/sheet/authentication/url', getAuthenticationUrl);
router.get('/sheet/authentication', getAuthentication);
router.get('/sheet/connect', verifyAuthentication);
// Google Sheet integration - Step 3
router.post('/sheet/sheetId', validateSheetId, postId);
router.post('/sheet/revoke', revokeAuthentication);
// Google Sheet integration - Step 4
router.post('/sheet/worksheet', validateWorksheet, postWorksheet);
router.post('/sheet/:sheetId/read', validateSheetOptions, readFromSheet);
// Google Sheet integration - Step 5
router.post('/sheet-pull', validateSheetOptions, pullSheet);
// Google Sheet integration - Step 6
router.post('/sheet-push', validateSheetOptions, pushSheet);
router.post('/sheet/:sheetId/write', validateSheetOptions, writeToSheet);
+4 -2
View File
@@ -1,4 +1,5 @@
import express from 'express';
import {
deleteCustomField,
getCustomFields,
@@ -10,6 +11,7 @@ import {
import {
projectSanitiser,
validateCustomField,
validateDeleteCustomField,
validateEditCustomField,
} from '../controllers/projectController.validate.js';
@@ -25,6 +27,6 @@ router.get('/custom-field', getCustomFields);
router.post('/custom-field', validateCustomField, postCustomField);
router.put('/custom-field', validateEditCustomField, putCustomField);
router.put('/custom-field/:label', validateEditCustomField, putCustomField);
router.delete('/custom-field/:label', deleteCustomField);
router.delete('/custom-field/:label', validateDeleteCustomField, deleteCustomField);
@@ -50,7 +50,6 @@ export class OscIntegration implements IIntegration<OscSubscription> {
this.enabledOut = enabledOut;
try {
logger.info(LogOrigin.Tx, 'Initialising OSC integration...');
this.oscClient = new Client(targetIP, portOut);
} catch (error) {
this.oscClient = null;
@@ -1,4 +1,5 @@
import {
CustomFields,
LogOrigin,
OntimeBlock,
OntimeDelay,
@@ -195,7 +196,16 @@ export function notifyChanges(options: { timer?: boolean | string[]; external?:
* Overrides the rundown with the given
* @param rundown
*/
export async function setRundown(rundown: OntimeRundown) {
await cache.init(rundown);
export async function initRundown(rundown: OntimeRundown, customFields: CustomFields) {
await cache.init(rundown, customFields);
notifyChanges({ timer: true });
}
/**
* Overrides the rundown with the given
* @param rundown
*/
export async function setRundown(rundown: OntimeRundown) {
await cache.setRundown(rundown);
notifyChanges({ timer: true });
}
@@ -1,5 +1,7 @@
import {
CustomFields,
EndAction,
EventCustomFields,
OntimeBlock,
OntimeDelay,
OntimeEvent,
@@ -10,7 +12,18 @@ import {
} from 'ontime-types';
import { calculateRuntimeDelays, getDelayAt, calculateRuntimeDelaysFrom } from '../delayUtils.js';
import { add, batchEdit, edit, generate, remove, reorder, swap } from '../rundownCache.js';
import {
add,
batchEdit,
edit,
generate,
remove,
reorder,
swap,
createCustomField,
editCustomField,
removeCustomField,
} from '../rundownCache.js';
describe('init() function', () => {
it('creates normalised versions of a given rundown', () => {
@@ -206,6 +219,51 @@ describe('init() function', () => {
expect((initResult.rundown['1'] as OntimeEvent).timeStart).toBe(1);
expect(Object.keys(initResult.links).length).toBe(0);
});
describe('custom properties feature', () => {
it('creates a map of custom properties', () => {
const customProperties: CustomFields = {
lighting: {
label: 'lighting',
type: 'string',
colour: 'red',
},
sound: {
label: 'sound',
type: 'string',
colour: 'red',
},
};
const testRundown: OntimeRundown = [
{
type: SupportedEvent.Event,
id: '1',
custom: {
lighting: { value: 'event 1 lx' },
} as EventCustomFields,
} as OntimeEvent,
{
type: SupportedEvent.Event,
id: '2',
custom: {
lighting: { value: 'event 2 lx' },
sound: { value: 'event 2 sound' },
} as EventCustomFields,
} as OntimeEvent,
];
const initResult = generate(testRundown, customProperties);
expect(initResult.order.length).toBe(2);
expect(initResult.assignedCustomProperties).toMatchObject({
lighting: ['1', '2'],
sound: ['2'],
});
expect((initResult.rundown['1'] as OntimeEvent).custom).toMatchObject({ lighting: { value: 'event 1 lx' } });
expect((initResult.rundown['2'] as OntimeEvent).custom).toMatchObject({
lighting: { value: 'event 2 lx' },
sound: { value: 'event 2 sound' },
});
});
});
});
describe('add() mutation', () => {
@@ -366,6 +424,7 @@ describe('calculateRuntimeDelays', () => {
timeDanger: 60000,
id: '659e1',
cue: '1',
custom: {},
},
{
duration: 600000,
@@ -403,6 +462,7 @@ describe('calculateRuntimeDelays', () => {
timeDanger: 60000,
id: '1c48f',
cue: '2',
custom: {},
},
{
duration: 1200000,
@@ -440,6 +500,7 @@ describe('calculateRuntimeDelays', () => {
timeDanger: 60000,
id: 'd48c2',
cue: '3',
custom: {},
},
{
title: '',
@@ -477,6 +538,7 @@ describe('calculateRuntimeDelays', () => {
timeDanger: 60000,
id: '2f185',
cue: '4',
custom: {},
},
];
@@ -524,6 +586,7 @@ describe('getDelayAt()', () => {
id: '659e1',
delay: 0,
cue: '1',
custom: {},
},
{
duration: 600000,
@@ -562,6 +625,7 @@ describe('getDelayAt()', () => {
id: '1c48f',
delay: 600000,
cue: '2',
custom: {},
},
{
duration: 1200000,
@@ -600,6 +664,7 @@ describe('getDelayAt()', () => {
id: 'd48c2',
delay: 1800000,
cue: '3',
custom: {},
},
{
title: '',
@@ -638,6 +703,7 @@ describe('getDelayAt()', () => {
id: '2f185',
delay: 0,
cue: '4',
custom: {},
},
];
@@ -702,6 +768,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
id: '659e1',
delay: 0,
cue: '1',
custom: {},
},
{
duration: 600000,
@@ -740,6 +807,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
id: '1c48f',
delay: 0,
cue: '2',
custom: {},
},
{
duration: 1200000,
@@ -778,6 +846,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
id: 'd48c2',
delay: 1800000,
cue: '3',
custom: {},
},
{
title: '',
@@ -816,6 +885,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
id: '2f185',
delay: 0,
cue: '4',
custom: {},
},
];
@@ -827,3 +897,76 @@ describe('calculateRuntimeDelaysFrom()', () => {
expect((updatedRundown[4] as OntimeEvent).delay).toBe(600000 + 1200000);
});
});
describe('custom fields', () => {
describe('createCustomField()', () => {
beforeEach(() => {
vi.mock('../../classes/data-provider/DataProvider.js', () => {
return {
DataProvider: {
...vi.fn().mockImplementation(() => {
return {};
}),
getCustomFields: vi.fn().mockReturnValue({}),
setCustomFields: vi.fn().mockImplementation((newData) => {
return newData;
}),
persist: vi.fn().mockReturnValue({}),
},
};
});
});
it('creates a field from given parameters', async () => {
const expected = {
lighting: {
label: 'lighting',
type: 'string',
colour: 'blue',
},
};
const customField = await createCustomField({ label: 'lighting', type: 'string', colour: 'blue' });
expect(customField).toStrictEqual(expected);
});
});
describe('editCustomField()', () => {
it('edits a field with a given label', async () => {
await createCustomField({ label: 'sound', type: 'string', colour: 'blue' });
const expected = {
lighting: {
label: 'lighting',
type: 'string',
colour: 'blue',
},
sound: {
label: 'sound',
type: 'string',
colour: 'blue',
},
};
const customField = await editCustomField('sound', { label: 'sound', type: 'string', colour: 'blue' });
expect(customField).toStrictEqual(expected);
});
});
describe('removeCustomField()', () => {
it('deletes a field with a given label', async () => {
const expected = {
lighting: {
label: 'lighting',
type: 'string',
colour: 'blue',
},
};
const customField = await removeCustomField('sound');
expect(customField).toStrictEqual(expected);
});
});
});
@@ -1,13 +1,14 @@
import { isOntimeDelay, isOntimeEvent, OntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import {
generateId,
deleteAtIndex,
insertAtIndex,
reorderArray,
swapEventData,
getLinkedTimes,
formatFromMillis,
} from 'ontime-utils';
CustomField,
CustomFieldLabel,
CustomFields,
isOntimeDelay,
isOntimeEvent,
OntimeEvent,
OntimeRundown,
OntimeRundownEntry,
} from 'ontime-types';
import { generateId, deleteAtIndex, insertAtIndex, reorderArray, swapEventData, getLinkedTimes } from 'ontime-utils';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { createPatch } from '../../utils/parser.js';
@@ -17,8 +18,11 @@ type EventID = string;
type NormalisedRundown = Record<EventID, OntimeRundownEntry>;
let persistedRundown: OntimeRundown = [];
/** Utility function gets rundown from DataProvider */
let persistedCustomFields: CustomFields = {};
/** Utility function gets to expose data */
export const getPersistedRundown = (): OntimeRundown => persistedRundown;
export const getCustomFields = (): CustomFields => persistedCustomFields;
let rundown: NormalisedRundown = {};
let order: EventID[] = [];
@@ -28,17 +32,38 @@ let totalDelay = 0;
let links: Record<EventID, EventID> = {};
export async function init(initialRundown: OntimeRundown) {
/**
* Object that contains renamings to custom fields
* Used to rename the custom fields in the events
* @example
* {
* oldLabel: newLabel
* lighting: lx
* }
*/
const customFieldChangelog = {};
const assignedCustomFields: Record<CustomFieldLabel, EventID[]> = {};
export async function init(initialRundown: OntimeRundown, customFields: CustomFields) {
persistedRundown = structuredClone(initialRundown);
persistedCustomFields = structuredClone(customFields);
generate();
await DataProvider.setRundown(persistedRundown);
}
export async function setRundown(initialRundown: OntimeRundown) {
persistedRundown = structuredClone(initialRundown);
generate();
await DataProvider.setRundown(persistedRundown);
}
/**
* Utility initialises cache
* @param rundown
*/
export function generate(initialRundown: OntimeRundown = persistedRundown) {
export function generate(
initialRundown: OntimeRundown = persistedRundown,
customProperties: CustomFields = persistedCustomFields,
) {
// we decided to re-write this dataset for every change
// instead of maintaining logic to update it
@@ -80,6 +105,21 @@ export function generate(initialRundown: OntimeRundown = persistedRundown) {
// update the persisted event
initialRundown[i] = updatedEvent;
}
if (updatedEvent.custom) {
for (const property in updatedEvent.custom) {
const isValid = property in customProperties;
if (!isValid) {
delete updatedEvent.custom[property];
return;
}
if (!Array.isArray(assignedCustomFields[property])) {
assignedCustomFields[property] = [];
}
assignedCustomFields[property].push(updatedEvent.id);
}
// update the persisted event
initialRundown[i] = updatedEvent;
}
}
// calculate delays
@@ -103,7 +143,7 @@ export function generate(initialRundown: OntimeRundown = persistedRundown) {
isStale = false;
totalDelay = accumulatedDelay;
return { rundown, order, links, totalDelay };
return { rundown, order, links, totalDelay, assignedCustomProperties: assignedCustomFields };
}
/** Returns an ID guaranteed to be unique */
@@ -241,11 +281,9 @@ export function edit({ persistedRundown, eventId, patch }: EditArgs): Required<M
throw new Error('Invalid event type');
}
// @ts-expect-error -- testing
console.log('patch', formatFromMillis(patch?.timeStart ?? 0, 'HH:mm:ss'));
const eventInMemory = persistedRundown[indexAt];
const newEvent = makeEvent(eventInMemory, patch);
console.log('got', patch, 'will make', newEvent);
const newRundown = [...persistedRundown];
newRundown[indexAt] = newEvent;
@@ -321,3 +359,73 @@ export function swap({ persistedRundown, fromId, toId }: SwapArgs): MutatingRetu
return { newRundown };
}
/**
* Sanitises and creates a custom field in the database
* @param field
* @returns
*/
export const createCustomField = async (field: CustomField) => {
const { label, type, colour } = field;
// check if label already exists
const alreadyExists = Object.hasOwn(persistedCustomFields, label);
if (alreadyExists) {
throw new Error('Label already exists');
}
// update object and persist
persistedCustomFields[label] = { label, type, colour };
setImmediate(() => {
DataProvider.setCustomFields(persistedCustomFields);
});
return persistedCustomFields;
};
/**
* Edits an existing custom field in the database
* @param label
* @param newField
* @returns
*/
export const editCustomField = async (label: string, newField: Partial<CustomField>) => {
if (!(label in persistedCustomFields)) {
throw new Error('Could not find label');
}
const existingField = persistedCustomFields[label];
if (existingField.type !== newField.type) {
throw new Error('Change of field type is not allowed');
}
if (existingField.label !== newField.label) {
customFieldChangelog[label] = newField.label;
}
persistedCustomFields[label] = { ...existingField, ...newField };
setImmediate(() => {
DataProvider.setCustomFields(persistedCustomFields);
});
return persistedCustomFields;
};
/**
* Deletes a custom field from the database
* @param label
*/
export const removeCustomField = async (label: string) => {
if (label in persistedCustomFields) {
delete persistedCustomFields[label];
}
setImmediate(() => {
DataProvider.setCustomFields(persistedCustomFields);
});
return persistedCustomFields;
};
@@ -0,0 +1,372 @@
/**
* Service aggregates business logic related
* to integration with Google Sheets API
* @link https://developers.google.com/identity/protocols/oauth2/limited-input-device
*/
import { AuthenticationStatus, LogOrigin, MaybeString, OntimeRundown, UserFields } from 'ontime-types';
import { sheets, sheets_v4 } from '@googleapis/sheets';
import { Credentials, OAuth2Client } from 'google-auth-library';
import got from 'got';
import { resolveSheetsDirectory } from '../../setup.js';
import { ensureDirectory } from '../../utils/fileManagement.js';
import { type ClientSecret, cellRequestFromEvent, getA1Notation, validateClientSecret } from './sheetUtils.js';
import { ExcelImportMap } from 'ontime-utils';
import { parseExcel } from '../../utils/parser.js';
import { logger } from '../../classes/Logger.js';
import { parseRundown, parseUserFields } from '../../utils/parserFunctions.js';
import { getRundown } from '../rundown-service/rundownUtils.js';
const sheetScope = 'https://www.googleapis.com/auth/spreadsheets';
const codesUrl = 'https://oauth2.googleapis.com/device/code';
const tokenUrl = 'https://oauth2.googleapis.com/token';
const grantType = 'urn:ietf:params:oauth:grant-type:device_code';
let currentAuthClient: OAuth2Client | null = null;
let currentClientSecret: ClientSecret | null = null;
let currentAuthUrl: MaybeString = null;
let currentAuthCode: MaybeString = null;
let currentSheetId: MaybeString = null;
let pollInterval: NodeJS.Timer | null = null;
let cleanupTimeout: NodeJS.Timeout | null = null;
function reset() {
currentAuthClient = null;
currentClientSecret = null;
currentAuthUrl = null;
currentAuthCode = null;
currentSheetId = null;
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
if (cleanupTimeout) {
clearTimeout(cleanupTimeout);
cleanupTimeout = null;
}
}
/**
* Initialise module
*/
export function init() {
reset();
ensureDirectory(resolveSheetsDirectory);
}
/**
* Resets all state related to an eventual connection
*/
export function revoke(): ReturnType<typeof hasAuth> {
reset();
return hasAuth();
}
/**
* Parses and validates a client secret string
* @param clientSecret
* @returns
*/
export function handleClientSecret(clientSecret: string): ClientSecret {
const clientSecretObject = JSON.parse(clientSecret);
const isValid = validateClientSecret(clientSecretObject);
if (!isValid) {
throw new Error('Client secret invalid');
}
return clientSecretObject;
}
// https://developers.google.com/identity/protocols/oauth2/limited-input-device#success-response
type CodesResponse = {
device_code: string;
expires_in: number;
interval: number;
user_code: string;
verification_url: string;
};
/**
* Establishes connection with Google Auth server
* and retrieves device codes
* @param clientSecret
* @returns
*/
async function getDeviceCodes(clientSecret: ClientSecret): Promise<CodesResponse> {
const deviceCodes: CodesResponse = await got
.post(codesUrl, {
json: {
client_id: clientSecret.installed.client_id,
scope: sheetScope,
},
})
.json();
return deviceCodes;
}
/**
* Gets credentials from Google Auth server
* @param clientSecret
* @param device_code
* @param interval
* @param expires_in
* @param postAction
*/
function verifyConnection(
clientSecret: ClientSecret,
device_code: string,
interval: number,
expires_in: number,
postAction: () => void,
) {
// create poller to check for auth
pollInterval = setInterval(pollForAuth, interval * 1000);
// schedule to clear the poller when we know the token is no longer valid
if (cleanupTimeout) {
clearTimeout(cleanupTimeout);
cleanupTimeout = null;
}
cleanupTimeout = setTimeout(() => {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}, expires_in * 1000);
async function pollForAuth() {
// server returns 428 if user hasnt yet completed the auth process
try {
logger.info(LogOrigin.Server, 'Polling for auth...');
const auth: Credentials = await got
.post(tokenUrl, {
json: {
client_id: clientSecret.installed.client_id,
client_secret: clientSecret.installed.client_secret,
device_code,
grant_type: grantType,
},
})
.json();
logger.info(LogOrigin.Server, 'Successfully Authenticated');
const client = new OAuth2Client({
clientId: clientSecret.installed.client_id,
clientSecret: clientSecret.installed.client_secret,
});
client.setCredentials({
refresh_token: auth.refresh_token,
access_token: auth.access_token,
scope: auth.scope,
token_type: auth.token_type,
});
// save client and cancel tasks
currentAuthClient = client;
if (cleanupTimeout) {
clearTimeout(cleanupTimeout);
cleanupTimeout = null;
}
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
postAction();
} catch (_error) {
/** we do not handle failure */
}
}
}
export function hasAuth(): { authenticated: AuthenticationStatus } {
if (cleanupTimeout) {
return { authenticated: 'pending' };
}
return { authenticated: currentAuthClient ? 'authenticated' : 'not_authenticated' };
}
async function verifySheet(
sheetId = currentSheetId,
authClient = currentAuthClient,
): Promise<{ worksheetOptions: string[] }> {
const spreadsheets = await sheets({ version: 'v4', auth: authClient }).spreadsheets.get({
spreadsheetId: sheetId,
includeGridData: false,
});
if (spreadsheets.status !== 200) {
throw new Error(spreadsheets.statusText);
}
return { worksheetOptions: spreadsheets.data.sheets.map((i) => i.properties.title) };
}
export async function handleInitialConnection(
clientSecret: ClientSecret,
sheetId: string,
): Promise<{ verification_url: string; user_code: string }> {
// TODO: check if the clientSecret has changed
currentClientSecret = clientSecret;
// we know there is an ongoing process if there is a timeout for cleanup
// if there is an ongoing process, we return its data
if (cleanupTimeout) {
return { verification_url: currentAuthUrl, user_code: currentAuthCode };
}
const { device_code, expires_in, interval, user_code, verification_url } = await getDeviceCodes(currentClientSecret);
currentAuthUrl = verification_url;
currentAuthCode = user_code;
currentSheetId = sheetId;
// schedule verifying token and the existence of the sheetID
verifyConnection(currentClientSecret, device_code, interval, expires_in, verifySheet);
return { verification_url, user_code };
}
/**
* Allow calling verification for sheetId
* @returns
*/
export async function getWorksheetOptions(sheetId: string): ReturnType<typeof verifySheet> {
if (!currentAuthClient) {
throw new Error('Not authenticated');
}
currentSheetId = sheetId;
return verifySheet(sheetId);
}
async function verifyWorksheet(sheetId: string, worksheet: string): Promise<{ worksheetId: number; range: string }> {
const spreadsheets = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.get({
spreadsheetId: sheetId,
});
if (spreadsheets.status !== 200) {
throw new Error(`Request failed: ${spreadsheets.status} ${spreadsheets.statusText}`);
}
const selectedWorksheet = spreadsheets.data.sheets.find((n) => n.properties.title == worksheet);
if (!selectedWorksheet) {
throw new Error('Could not find worksheet');
}
const endCell = getA1Notation(
selectedWorksheet.properties.gridProperties.rowCount,
selectedWorksheet.properties.gridProperties.columnCount,
);
return { worksheetId: selectedWorksheet.properties.sheetId, range: `${worksheet}!A1:${endCell}` };
}
export async function upload(sheetId: string, options: ExcelImportMap) {
const { worksheetId, range } = await verifyWorksheet(sheetId, options.worksheet);
const readResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.values.get({
spreadsheetId: sheetId,
valueRenderOption: 'FORMATTED_VALUE',
majorDimension: 'ROWS',
range,
});
if (readResponse.status !== 200) {
throw new Error(`Sheet read failed: ${readResponse.statusText}`);
}
const { rundownMetadata } = parseExcel(readResponse.data.values, options);
const rundown = getRundown();
const titleRow = Object.values(rundownMetadata)[0]['row'];
const updateRundown = Array<sheets_v4.Schema$Request>();
// we can't delete the last unfrozen row so we create an empty one
updateRundown.push({
insertDimension: {
inheritFromBefore: false,
range: {
dimension: 'ROWS',
startIndex: titleRow + 1,
endIndex: titleRow + 2,
sheetId: worksheetId,
},
},
});
// ... and delete the rest
updateRundown.push({
deleteDimension: { range: { dimension: 'ROWS', startIndex: titleRow + 2, sheetId: worksheetId } },
});
// insert the length of the rundown
updateRundown.push({
insertDimension: {
inheritFromBefore: false,
range: {
dimension: 'ROWS',
startIndex: titleRow + 1,
endIndex: titleRow + rundown.length,
sheetId: worksheetId,
},
},
});
// update the corresponding row with event data
rundown.forEach((entry, index) =>
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, rundownMetadata)),
);
const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({
spreadsheetId: sheetId,
requestBody: {
includeSpreadsheetInResponse: false,
responseRanges: [range],
requests: updateRundown,
},
});
if (writeResponse.status === 200) {
logger.info(LogOrigin.Server, `Sheet write ${writeResponse.statusText}`);
} else {
throw new Error(`Sheet write failed: ${writeResponse.statusText}`);
}
}
export async function download(
sheetId: string,
options: ExcelImportMap,
): Promise<{
rundown: OntimeRundown;
userFields: UserFields;
}> {
const { range } = await verifyWorksheet(sheetId, options.worksheet);
const googleResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.values.get({
spreadsheetId: sheetId,
valueRenderOption: 'FORMATTED_VALUE',
majorDimension: 'ROWS',
range,
});
if (googleResponse.status !== 200) {
throw new Error(`Sheet read failed: ${googleResponse.statusText}`);
}
const dataFromSheet = parseExcel(googleResponse.data.values, options);
const rundown = parseRundown(dataFromSheet);
if (rundown.length < 1) {
throw new Error('Sheet: Could not find data to import in the worksheet');
}
const userFields = parseUserFields(dataFromSheet);
return { rundown, userFields };
}
@@ -1,4 +1,4 @@
import { EndAction, OntimeRundownEntry, SupportedEvent, TimeStrategy, TimerType } from 'ontime-types';
import { EndAction, OntimeEvent, SupportedEvent, TimeStrategy, TimerType } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { getA1Notation, cellRequestFromEvent } from '../sheetUtils.js';
@@ -20,7 +20,7 @@ describe('getA1Notation()', () => {
describe('cellRequestFromEvent()', () => {
test('string to string', () => {
const event: OntimeRundownEntry = {
const event: OntimeEvent = {
type: SupportedEvent.Event,
cue: '1',
title: 'Fancy',
@@ -51,6 +51,7 @@ describe('cellRequestFromEvent()', () => {
id: '1358',
timeWarning: 0,
timeDanger: 0,
custom: {},
};
const metadata = {
type: { row: 1, col: 14 },
@@ -87,7 +88,7 @@ describe('cellRequestFromEvent()', () => {
});
test('numer to timer', () => {
const event: OntimeRundownEntry = {
const event: OntimeEvent = {
type: SupportedEvent.Event,
cue: '1',
title: 'Fancy',
@@ -118,6 +119,7 @@ describe('cellRequestFromEvent()', () => {
id: '1358',
timeWarning: 0,
timeDanger: 0,
custom: {},
};
const metadata = {
type: { row: 1, col: 14 },
@@ -155,7 +157,7 @@ describe('cellRequestFromEvent()', () => {
});
test('boolean to x', () => {
const event: OntimeRundownEntry = {
const event: OntimeEvent = {
type: SupportedEvent.Event,
cue: '1',
title: 'Fancy',
@@ -186,6 +188,7 @@ describe('cellRequestFromEvent()', () => {
id: '1358',
timeWarning: 0,
timeDanger: 0,
custom: {},
};
const metadata = {
type: { row: 1, col: 14 },
@@ -223,7 +226,7 @@ describe('cellRequestFromEvent()', () => {
});
test('spacing in metadata', () => {
const event: OntimeRundownEntry = {
const event: OntimeEvent = {
type: SupportedEvent.Event,
cue: '1',
title: 'Fancy',
@@ -254,6 +257,7 @@ describe('cellRequestFromEvent()', () => {
id: '1358',
timeWarning: 0,
timeDanger: 0,
custom: {},
};
const metadata = {
cue: { row: 1, col: 0 },
@@ -268,7 +272,7 @@ describe('cellRequestFromEvent()', () => {
});
test('metadata offset from zero', () => {
const event: OntimeRundownEntry = {
const event: OntimeEvent = {
type: SupportedEvent.Event,
cue: '1',
title: 'Fancy',
@@ -299,6 +303,7 @@ describe('cellRequestFromEvent()', () => {
id: '1358',
timeWarning: 0,
timeDanger: 0,
custom: {},
};
const metadata = {
cue: { row: 1, col: 5 },
@@ -313,7 +318,7 @@ describe('cellRequestFromEvent()', () => {
});
test('sheet setup', () => {
const event: OntimeRundownEntry = {
const event: OntimeEvent = {
type: SupportedEvent.Event,
cue: '1',
title: 'Fancy',
@@ -344,6 +349,7 @@ describe('cellRequestFromEvent()', () => {
id: '1358',
timeWarning: 0,
timeDanger: 0,
custom: {},
};
const metadata = {
cue: { row: 10, col: 5 },
@@ -1,6 +1,36 @@
import { sheets_v4 } from '@googleapis/sheets';
import { millisToString } from 'ontime-utils';
import { OntimeRundownEntry, isOntimeBlock, isOntimeEvent } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { sheets_v4 } from '@googleapis/sheets';
// we expect client secret file to contain the following keys
const requiredClientKeys = [
'client_id',
'auth_uri',
'token_uri',
'token_uri',
'auth_provider_x509_cert_url',
'client_secret',
];
export type ClientSecret = {
installed: {
client_id: string;
auth_uri: string;
token_uri: string;
auth_provider_x509_cert_url: string;
client_secret: string;
};
};
/**
* Guard validates a given client secrets file
* @param clientSecret
* @returns
*/
export function validateClientSecret(clientSecret: object): clientSecret is ClientSecret {
return requiredClientKeys.every((key) => Object.keys(clientSecret['installed']).includes(key));
}
/**
*
@@ -21,10 +51,12 @@ export function getA1Notation(row: number, column: number): string {
const a1Notation = [`${row + 1}`];
const 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('');
}
+3
View File
@@ -124,5 +124,8 @@ export const pathToStartDemo = config.demo.filename.map((file) => {
// path to restore file
export const resolveRestoreFile = join(getAppDataPath(), config.restoreFile);
// path to sheets folder
export const resolveSheetsDirectory = join(getAppDataPath(), config.sheets.directory);
// path to crash reports
export const resolveCrashReportDirectory = getAppDataPath();
@@ -1,67 +0,0 @@
import { createCustomField, editCustomField, removeCustomField } from '../customFields.js';
describe('createCustomField()', () => {
beforeEach(() => {
vi.mock('../../classes/data-provider/DataProvider.js', () => {
return {
DataProvider: {
...vi.fn().mockImplementation(() => {
return {};
}),
getCustomFields: vi.fn().mockReturnValue({}),
setCustomFields: vi.fn().mockImplementation((newData) => {
return newData;
}),
},
};
});
});
it('creates a field from given parameters', async () => {
const expected = {
lighting: {
label: 'lighting',
type: 'text',
},
};
const customField = await createCustomField({ label: 'lighting', type: 'text' });
expect(customField).toStrictEqual(expected);
});
});
describe('editCustomField()', () => {
it('edits a field with a given label', async () => {
await createCustomField({ label: 'sound', type: 'text' });
const expected = {
lighting: {
label: 'lighting',
type: 'text',
},
sound: {
label: 'sound',
type: 'number',
},
};
const customField = await editCustomField('sound', { label: 'sound', type: 'number' });
expect(customField).toStrictEqual(expected);
});
});
describe('removeCustomField()', () => {
it('deletes a field with a given label', async () => {
const expected = {
lighting: {
label: 'lighting',
type: 'text',
},
};
const customField = await removeCustomField('sound');
expect(customField).toStrictEqual(expected);
});
});
@@ -52,6 +52,7 @@ describe('test json parser with valid def', () => {
revision: 0,
timeWarning: 0,
timeDanger: 0,
custom: {},
},
{
id: 'f24d',
@@ -84,6 +85,7 @@ describe('test json parser with valid def', () => {
revision: 0,
timeWarning: 0,
timeDanger: 0,
custom: {},
},
{
id: 'bbc5',
@@ -116,6 +118,7 @@ describe('test json parser with valid def', () => {
revision: 0,
timeWarning: 0,
timeDanger: 0,
custom: {},
},
{
// testing incomplete dataset
@@ -169,6 +172,7 @@ describe('test json parser with valid def', () => {
revision: 0,
timeWarning: 0,
timeDanger: 0,
custom: {},
},
{
id: '08e9',
@@ -201,6 +205,7 @@ describe('test json parser with valid def', () => {
revision: 0,
timeWarning: 0,
timeDanger: 0,
custom: {},
},
{
// testing incomplete dataset
-63
View File
@@ -1,63 +0,0 @@
import { isAlphanumeric } from 'ontime-utils';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { CustomField } from 'ontime-types';
/**
* Sanitises and creates a custom field in the database
* @param field
* @returns
*/
export const createCustomField = async (field: CustomField) => {
if (!isAlphanumeric(field.label)) {
throw new Error('Label must be Alphanumeric');
}
const customFields = DataProvider.getCustomFields();
if (Object.keys(customFields).find((f) => f === field.label) !== undefined) {
throw new Error('Label already exists');
}
Object.assign(customFields, { [field.label]: field });
const newCustomFields = await DataProvider.setCustomFields(customFields);
return newCustomFields;
};
/**
* Edits an existing custom field in the database
* @param label
* @param field
* @returns
*/
export const editCustomField = async (label: string, field: Partial<CustomField>) => {
const existingFields = DataProvider.getCustomFields();
if (!(label in existingFields)) {
throw new Error('Could not find label');
}
const existingField = existingFields[label];
if (!existingField) {
throw new Error('Could not find label');
}
existingFields[label] = { ...existingField, ...field };
const newCustomFields = await DataProvider.setCustomFields(existingFields);
return newCustomFields;
};
/**
* Deletes a custom field from the database
* @param label
*/
export const removeCustomField = async (label: string) => {
const existingFields = DataProvider.getCustomFields();
if (!(label in existingFields)) {
throw new Error('Could not find label');
}
delete existingFields[label];
const newCustomFields = await DataProvider.setCustomFields(existingFields);
return newCustomFields;
};
+1
View File
@@ -402,6 +402,7 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
revision: originalEvent.revision,
timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning,
timeDanger: patchEvent.timeDanger ?? originalEvent.timeDanger,
custom: patchEvent.custom ?? originalEvent.custom,
};
}
+11 -11
View File
@@ -1,4 +1,4 @@
import fs from 'fs';
import { unlink, readFileSync } from 'fs';
import { deepmerge } from 'ontime-utils';
/**
@@ -18,10 +18,9 @@ export const makeString = (val: unknown, fallback = ''): string => {
* @param {string} file - reference to file
*/
export const deleteFile = async (file) => {
// delete a file
fs.unlink(file, (err) => {
if (err) {
console.log(err);
unlink(file, (error) => {
if (error) {
console.error('Could not delete file:', error);
}
});
};
@@ -33,7 +32,7 @@ export const deleteFile = async (file) => {
*/
export const validateFile = (file) => {
try {
JSON.parse(fs.readFileSync(file, 'utf-8'));
JSON.parse(readFileSync(file, 'utf-8'));
return true;
} catch (err) {
return false;
@@ -83,9 +82,10 @@ export function mergeObject<T extends object>(a: T, b: Partial<T>): T {
* @param {object} obj
*/
export const removeUndefined = (obj: object) => {
const patched = {};
Object.keys({ ...obj })
.filter((key) => typeof obj[key] !== 'undefined')
.map((key) => (patched[key] = obj[key]));
return patched;
return Object.keys(obj).reduce((patched, key) => {
if (typeof obj[key] !== 'undefined') {
patched[key] = obj[key];
}
return patched;
}, {});
};
-375
View File
@@ -1,375 +0,0 @@
import { DatabaseModel, LogOrigin } from 'ontime-types';
import { ExcelImportMap } from 'ontime-utils';
import { sheets, sheets_v4 } from '@googleapis/sheets';
import { writeFile } from 'fs/promises';
import { readFileSync } from 'fs';
import { OAuth2Client } from 'google-auth-library';
import http from 'http';
import { join } from 'path';
import { URL } from 'url';
import { logger } from '../classes/Logger.js';
import { getAppDataPath } from '../setup.js';
import { ensureDirectory } from './fileManagement.js';
import { cellRequestFromEvent, getA1Notation } from './sheetUtils.js';
import { parseExcel } from './parser.js';
import { parseRundown, parseUserFields } from './parserFunctions.js';
import { getRundown } from '../services/rundown-service/rundownUtils.js';
type ResponseOK = {
data: Partial<DatabaseModel>;
};
class Sheet {
private static client: null | OAuth2Client = null;
private readonly scope = 'https://www.googleapis.com/auth/spreadsheets';
private readonly sheetsFolder: string;
private readonly clientSecretFile: string;
private static clientSecret = null;
private static authUrl: null | string = null;
private authServerTimeout;
private readonly requiredClientKeys = [
'client_id',
'project_id',
'auth_uri',
'token_uri',
'auth_provider_x509_cert_url',
'client_secret',
'redirect_uris',
];
constructor() {
const appDataPath = getAppDataPath();
this.sheetsFolder = join(appDataPath, 'sheets');
this.clientSecretFile = join(this.sheetsFolder, 'client_secret.json');
ensureDirectory(this.sheetsFolder);
try {
const secrets = JSON.parse(readFileSync(this.clientSecretFile, 'utf-8'));
const isKeyMissing = this.requiredClientKeys.some((key) => !(key in secrets['installed']));
if (!isKeyMissing) {
Sheet.clientSecret = secrets;
}
} catch (_) {
/* empty - it is ok that there is no clientSecret */
}
}
/**
* @description STEP 1 - saves secrets object to appdata path as client_secret.json
* @param {object} secrets
* @throws
*/
public async saveClientSecrets(secrets: object) {
Sheet.client = null;
Sheet.authUrl = null;
Sheet.clientSecret = null;
const isKeyMissing = this.requiredClientKeys.some((key) => !(key in secrets['installed']));
if (isKeyMissing) {
throw new Error('Client file is missing some keys');
}
await writeFile(this.clientSecretFile, JSON.stringify(secrets), 'utf-8').catch((err) => {
throw new Error(`Unable to save client file to disk ${err}`);
});
Sheet.clientSecret = secrets;
}
/**
* @description STEP 1 - test that the saved object is present
*/
testClientSecret() {
return Sheet.clientSecret !== null;
}
/**
* @description STEP 2 - create server to interact with th OAuth2 request
* @returns {Promise<string | null>} - returns url path serve on success
* @throws
*/
async openAuthServer(): Promise<string | null> {
//TODO: this only works in local networks
// if the server is already running return it
if (Sheet.authUrl) {
clearTimeout(this.authServerTimeout);
this.authServerTimeout = setTimeout(() => {
Sheet.authUrl = null;
server.unref();
}, 120000);
return Sheet.authUrl;
}
// Check that Secret is valid
const keyFile = Sheet.clientSecret;
const keys = keyFile.installed || keyFile.web;
if (!keys.redirect_uris || keys.redirect_uris.length === 0) {
throw new Error('Sheet: Missing redirect URI');
}
const redirectUri = new URL(keys.redirect_uris[0]);
if (redirectUri.hostname !== 'localhost') {
throw new Error('Sheet: Invalid redirect URI');
}
// create an oAuth client to authorize the API call
const client = new OAuth2Client({
clientId: keys.client_id,
clientSecret: keys.client_secret,
});
// start the server that will recive the codes
const server = http.createServer(async (req, res) => {
try {
const serverUrl = new URL(req.url, 'http://localhost:3000');
if (serverUrl.pathname !== redirectUri.pathname) {
res.end('Invalid callback URL');
return;
}
const searchParams = serverUrl.searchParams;
if (searchParams.has('error')) {
res.end('Authorization rejected.');
logger.info(LogOrigin.Server, `Sheet: ${searchParams.get('error')}`);
return;
}
if (!searchParams.has('code')) {
res.end('No authentication code provided.');
logger.info(LogOrigin.Server, 'Sheet: Cannot read authentication code');
return;
}
const code = searchParams.get('code');
const { tokens } = await client.getToken({
code,
redirect_uri: redirectUri.toString(),
});
client.credentials = tokens;
Sheet.client = client;
res.end('Authentication successful! Please close this tab and return to OnTime.');
logger.info(LogOrigin.Server, 'Sheet: Authentication successful');
} catch (e) {
logger.error(LogOrigin.Server, `Sheet: ${e}`);
} finally {
server.close();
}
});
let listenPort = 3000;
if (keyFile.installed) {
// Use ephemeral port if not a web client
listenPort = 0;
} else if (redirectUri.port !== '') {
listenPort = Number(redirectUri.port);
}
//TODO: the server might not start correctly
server.listen(listenPort);
const address = server.address();
if (typeof address !== 'string') {
redirectUri.port = String(address.port);
}
// open the browser to the authorize url to start the workflow
const authorizeUrl = client.generateAuthUrl({
redirect_uri: redirectUri.toString(),
access_type: 'offline',
scope: this.scope,
});
Sheet.authUrl = authorizeUrl;
this.authServerTimeout = setTimeout(
() => {
Sheet.authUrl = null;
server.unref();
},
2 * 60 * 1000,
);
return authorizeUrl;
}
/**
* @description STEP 2 - test that the reciveed OAuth2 is still valid
* @throws
*/
async testAuthentication() {
if (Sheet.client) {
const ref = await Sheet.client.refreshAccessToken();
if (ref.credentials.expiry_date > 10000) {
return true;
} else {
throw new Error('Unable to use access token');
}
} else {
throw new Error('Unable to authenticate');
}
}
/**
* @description STEP 3 - test the given sheet id
* @throws
*/
async testSheetId(sheetId: string) {
const spreadsheets = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.get({
spreadsheetId: sheetId,
includeGridData: false,
});
if (spreadsheets.status !== 200) {
throw new Error(spreadsheets.statusText);
}
return { worksheetOptions: spreadsheets.data.sheets.map((i) => i.properties.title) };
}
/**
* @description STEP 4 - test the given worksheet
* @throws
*/
async testWorksheet(sheetId: string, worksheet: string) {
const spreadsheets = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.get({
spreadsheetId: sheetId,
includeGridData: false,
});
if (spreadsheets.status !== 200) {
throw new Error(spreadsheets.statusText);
}
const worksheetExist = spreadsheets.data.sheets.find((i) => i.properties.title === worksheet);
if (!worksheetExist) {
throw new Error('Unable to find worksheet');
}
}
/**
* test existence of sheet and worksheet
* @param {string} sheetId - https://docs.google.com/spreadsheets/d/[[spreadsheetId]]/edit#gid=0
* @param {string} worksheet - the name of the worksheet containing ontime data
* @returns {Promise<{worksheetId: number, range: string}>} - id of worksheet and rage of worksheet
* @throws
*/
private async exist(sheetId: string, worksheet: string): Promise<{ worksheetId: number; range: string }> {
const spreadsheets = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.get({
spreadsheetId: sheetId,
});
if (spreadsheets.status !== 200) {
throw new Error(`Request failed: ${spreadsheets.status} ${spreadsheets.statusText}`);
}
const ourWorksheetData = spreadsheets.data.sheets.find((n) => n.properties.title == worksheet);
if (ourWorksheetData !== undefined) {
const endCell = getA1Notation(
ourWorksheetData.properties.gridProperties.rowCount,
ourWorksheetData.properties.gridProperties.columnCount,
);
return { worksheetId: ourWorksheetData.properties.sheetId, range: `${worksheet}!A1:${endCell}` };
}
}
/**
* @description STEP 5 - Upload the rundown to sheet
* @param {string} id - id of the sheet https://docs.google.com/spreadsheets/d/[[spreadsheetId]]/edit#gid=0
* @param {ExcelImportMap} options
* @throws
*/
public async push(id: string, options: ExcelImportMap) {
const { worksheetId, range } = await this.exist(id, options.worksheet);
const readResponse = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.values.get({
spreadsheetId: id,
valueRenderOption: 'FORMATTED_VALUE',
majorDimension: 'ROWS',
range,
});
if (readResponse.status === 200) {
const { rundownMetadata } = parseExcel(readResponse.data.values, options);
const rundown = getRundown();
const titleRow = Object.values(rundownMetadata)[0]['row'];
const updateRundown = Array<sheets_v4.Schema$Request>();
// we can't delete the last unfrozen row so we create an empty one
updateRundown.push({
insertDimension: {
inheritFromBefore: false,
range: {
dimension: 'ROWS',
startIndex: titleRow + 1,
endIndex: titleRow + 2,
sheetId: worksheetId,
},
},
});
//and delete the rest
updateRundown.push({
deleteDimension: { range: { dimension: 'ROWS', startIndex: titleRow + 2, sheetId: worksheetId } },
});
// insert the length of the rundown
updateRundown.push({
insertDimension: {
inheritFromBefore: false,
range: {
dimension: 'ROWS',
startIndex: titleRow + 1,
endIndex: titleRow + rundown.length,
sheetId: worksheetId,
},
},
});
//update the corresponding row with event data
rundown.forEach((entry, index) =>
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, rundownMetadata)),
);
const writeResponse = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.batchUpdate({
spreadsheetId: id,
requestBody: {
includeSpreadsheetInResponse: false,
responseRanges: [range],
requests: updateRundown,
},
});
if (writeResponse.status === 200) {
logger.info(LogOrigin.Server, `Sheet: write: ${writeResponse.statusText}`);
} else {
throw new Error(`Sheet: write failed: ${writeResponse.statusText}`);
}
} else {
throw new Error(`Sheet: read failed: ${readResponse.statusText}`);
}
}
/**
* @description STEP 5 - Download the rundown from sheet
* @param {string} sheetId - id of the sheet https://docs.google.com/spreadsheets/d/[[spreadsheetId]]/edit#gid=0
* @param {ExcelImportMap} options
* @returns {Promise<Partial<ResponseOK>>}
* @throws
*/
public async pull(sheetId: string, options: ExcelImportMap): Promise<Partial<ResponseOK>> {
const { range } = await this.exist(sheetId, options.worksheet);
const res: Partial<ResponseOK> = {};
const googleResponse = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.values.get({
spreadsheetId: sheetId,
valueRenderOption: 'FORMATTED_VALUE',
majorDimension: 'ROWS',
range,
});
// TODO: we need to pass this into a service that can safely merge the datasets
if (googleResponse.status === 200) {
res.data = {};
const dataFromSheet = parseExcel(googleResponse.data.values, options);
res.data.rundown = parseRundown(dataFromSheet);
if (res.data.rundown.length < 1) {
throw new Error('Sheet: Could not find data to import in the worksheet');
}
res.data.userFields = parseUserFields(dataFromSheet);
return res;
} else {
throw new Error(`Sheet: read failed: ${googleResponse.statusText}`);
}
}
}
export const sheet = new Sheet();
+15 -3
View File
@@ -65,11 +65,10 @@ const storage = multer.diskStorage({
* @argument file - reference to file
* @return {boolean} - file allowed
*/
const filterAllowed = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
const filterUserFile = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(JSON_MIME) || file.mimetype.includes(EXCEL_MIME)) {
cb(null, true);
} else {
console.error('ERROR: Unrecognised file type');
cb(null, false);
}
};
@@ -77,5 +76,18 @@ const filterAllowed = (_req: Request, file: Express.Multer.File, cb: FileFilterC
// Build multer uploader for a single file
export const uploadFile = multer({
storage,
fileFilter: filterAllowed,
fileFilter: filterUserFile,
}).single('userFile');
const filterClientSecret = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(JSON_MIME)) {
cb(null, true);
} else {
cb(null, false);
}
};
export const uploadClientSecret = multer({
storage,
fileFilter: filterClientSecret,
}).single('client_secret');