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
@@ -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 };
}
@@ -0,0 +1,367 @@
import { EndAction, OntimeEvent, SupportedEvent, TimeStrategy, TimerType } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { getA1Notation, cellRequestFromEvent } from '../sheetUtils.js';
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('cellRequestFromEvent()', () => {
test('string to string', () => {
const event: OntimeEvent = {
type: SupportedEvent.Event,
cue: '1',
title: 'Fancy',
subtitle: 'Wow',
presenter: 'Mr. Presenter',
note: 'Blue button on the right',
timeStart: 46800000,
timeEnd: 57600000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
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',
timeWarning: 0,
timeDanger: 0,
custom: {},
};
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 },
timeWarning: { row: 1, col: 40 },
timeDanger: { row: 1, col: 41 },
};
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: OntimeEvent = {
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,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
isPublic: false,
skip: false,
colour: 'red',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
revision: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
custom: {},
};
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 },
timeWarning: { row: 1, col: 40 },
timeDanger: { row: 1, col: 41 },
};
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: OntimeEvent = {
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,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
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',
timeWarning: 0,
timeDanger: 0,
custom: {},
};
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 },
timeWarning: { row: 1, col: 40 },
timeDanger: { row: 1, col: 41 },
};
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: OntimeEvent = {
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,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
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',
timeWarning: 0,
timeDanger: 0,
custom: {},
};
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: OntimeEvent = {
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,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
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',
timeWarning: 0,
timeDanger: 0,
custom: {},
};
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: OntimeEvent = {
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,
timeStrategy: TimeStrategy.LockEnd,
linkStart: null,
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',
timeWarning: 0,
timeDanger: 0,
custom: {},
};
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');
});
});
@@ -0,0 +1,148 @@
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));
}
/**
*
* @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]) },
});
} 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({});
}
} else if (isOntimeBlock(event)) {
if (key === 'title') {
returnRows.push({
userEnteredValue: { stringValue: event[key] },
});
} else if (key === 'timerType') {
returnRows.push({
userEnteredValue: { stringValue: 'block' },
});
} else {
returnRows.push({});
}
}
});
return {
updateCells: {
start: {
sheetId: worksheetId,
rowIndex: index + tmp[0][1]['row'] + 1,
columnIndex: titleCol,
},
fields: 'userEnteredValue',
rows: [
{
values: returnRows,
},
],
},
};
}