chore: cleanup directory structure

This commit is contained in:
Carlos Valente
2025-11-02 13:06:33 +01:00
committed by Carlos Valente
parent e6070e6efd
commit b75b69c3b1
5 changed files with 11 additions and 8 deletions
@@ -0,0 +1,285 @@
import { EndAction, OntimeEvent, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { getA1Notation, cellRequestFromEvent } from '../sheets.utils.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: SupportedEntry.Event,
flag: false,
cue: '1',
title: 'Fancy',
note: 'Blue button on the right',
timeStart: 46800000,
timeEnd: 57600000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: false,
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
duration: 10800000,
skip: false,
colour: 'red',
delay: 0,
gap: 0,
dayOffset: 0,
parent: null,
revision: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
triggers: [],
custom: {},
};
const metadata = {
type: { row: 1, col: 14 },
cue: { row: 1, col: 15 },
title: { row: 1, col: 16 },
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 },
skip: { row: 1, col: 26 },
colour: { row: 1, col: 27 },
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?.at(0)?.values?.at(5)?.userEnteredValue?.stringValue).toStrictEqual(event.note);
});
test('number to timer', () => {
const event: OntimeEvent = {
type: SupportedEntry.Event,
flag: false,
cue: '1',
title: 'Fancy',
note: 'Blue button on the right',
timeStart: 46800000,
timeEnd: 57600000,
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
duration: 10800000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: false,
skip: false,
colour: 'red',
parent: null,
revision: 0,
delay: 0,
gap: 0,
dayOffset: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
triggers: [],
custom: {},
};
const metadata = {
type: { row: 1, col: 14 },
cue: { row: 1, col: 15 },
title: { row: 1, col: 16 },
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 },
skip: { row: 1, col: 26 },
colour: { row: 1, col: 27 },
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?.at(0)?.values?.at(10)?.userEnteredValue?.stringValue).toStrictEqual(
millisToString(event.duration),
);
});
test('boolean to TRUE', () => {
const event: OntimeEvent = {
type: SupportedEntry.Event,
flag: false,
cue: '1',
title: 'Fancy',
note: 'Blue button on the right',
timeStart: 46800000,
timeEnd: 57600000,
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
duration: 10800000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: false,
skip: false,
colour: 'red',
parent: null,
revision: 0,
delay: 0,
gap: 0,
dayOffset: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
triggers: [],
custom: {},
};
const metadata = {
type: { row: 1, col: 14 },
cue: { row: 1, col: 15 },
title: { row: 1, col: 16 },
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 },
skip: { row: 1, col: 26 },
colour: { row: 1, col: 27 },
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?.at(0)?.values?.at(12)?.userEnteredValue?.boolValue).toStrictEqual(false);
});
test('spacing in metadata', () => {
const event: OntimeEvent = {
type: SupportedEntry.Event,
flag: false,
cue: '1',
title: 'Fancy',
note: 'Blue button on the right',
timeStart: 46800000,
timeEnd: 57600000,
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
timeStrategy: TimeStrategy.LockEnd,
linkStart: false,
duration: 10800000,
skip: false,
colour: 'red',
delay: 0,
gap: 0,
dayOffset: 0,
parent: null,
revision: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
triggers: [],
custom: {},
};
const metadata = {
cue: { row: 1, col: 0 },
title: { row: 1, col: 6 },
};
const result = cellRequestFromEvent(event, 1, 1234, metadata);
expect(result.updateCells?.rows?.at(0)?.values?.at(0)?.userEnteredValue?.stringValue).toStrictEqual(event.cue);
expect(result.updateCells?.rows?.at(0)?.values?.at(6)?.userEnteredValue?.stringValue).toStrictEqual(event.title);
});
test('metadata offset from zero', () => {
const event: OntimeEvent = {
type: SupportedEntry.Event,
flag: false,
cue: '1',
title: 'Fancy',
note: 'Blue button on the right',
timeStart: 46800000,
timeEnd: 57600000,
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
duration: 10800000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: false,
skip: false,
colour: 'red',
parent: null,
revision: 0,
delay: 0,
gap: 0,
dayOffset: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
triggers: [],
custom: {},
};
const metadata = {
cue: { row: 1, col: 5 },
title: { row: 1, col: 6 },
user0: { row: 1, col: 16 },
};
const result = cellRequestFromEvent(event, 1, 1234, metadata);
expect(result.updateCells?.rows?.at(0)?.values?.at(0)?.userEnteredValue?.stringValue).toStrictEqual(event.cue);
expect(result.updateCells?.rows?.at(0)?.values?.at(1)?.userEnteredValue?.stringValue).toStrictEqual(event.title);
});
test('sheet setup', () => {
const event: OntimeEvent = {
type: SupportedEntry.Event,
flag: false,
cue: '1',
title: 'Fancy',
note: 'Blue button on the right',
timeStart: 46800000,
timeEnd: 57600000,
endAction: EndAction.None,
timerType: TimerType.CountDown,
countToEnd: false,
duration: 10800000,
timeStrategy: TimeStrategy.LockEnd,
linkStart: false,
skip: false,
colour: 'red',
parent: null,
revision: 0,
delay: 0,
gap: 0,
dayOffset: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
triggers: [],
custom: {},
};
const metadata = {
cue: { row: 10, col: 5 },
title: { row: 10, col: 6 },
};
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,userEnteredFormat');
});
});
@@ -0,0 +1,39 @@
// https://developers.google.com/calendar/api/guides/errors
interface GoogleApiError {
code: number;
message: string;
errors?: {
message: string;
domain: string;
reason: string;
}[];
}
/**
* Checks whether an error is a Google API error
*/
function isGoogleApiError(error: any): error is GoogleApiError {
return (
typeof error === 'object' &&
error !== null &&
typeof error.code === 'number' &&
Array.isArray(error.errors) &&
typeof error.errors[0]?.reason === 'string' &&
typeof error.errors[0]?.message === 'string'
);
}
/**
* Extract utility to handle a common error where a user imports an xlsx file instead of a Google Sheet
*/
export function catchCommonImportXlsxError(error: any) {
if (
isGoogleApiError(error) &&
error.code === 400 &&
Array.isArray(error.errors) &&
error.errors[0].reason === 'failedPrecondition' &&
error.errors[0].message === 'This operation is not supported for this document'
) {
throw new Error('Cannot read the linked file as a Google Sheet. It may be an .xlsx file instead.');
}
}
@@ -9,6 +9,8 @@ import { getErrorMessage } from 'ontime-utils';
import { Request, Response } from 'express';
import { readFileSync } from 'fs';
import { deleteFile } from '../../utils/fileManagement.js';
import {
revoke,
handleClientSecret,
@@ -17,8 +19,7 @@ import {
download,
upload,
getWorksheetOptions,
} from '../../services/sheet-service/SheetService.js';
import { deleteFile } from '../../utils/fileManagement.js';
} from './sheets.service.js';
export async function requestConnection(
req: Request,
@@ -0,0 +1,523 @@
/**
* Service aggregates business logic related
* to integration with Google Sheets API
* @link https://developers.google.com/identity/protocols/oauth2/limited-input-device
*/
import {
AuthenticationStatus,
CustomFields,
DatabaseModel,
EntryId,
isOntimeEvent,
isOntimeMilestone,
LogOrigin,
MaybeString,
OntimeGroup,
Rundown,
RundownSummary,
SupportedEntry,
} from 'ontime-types';
import { ImportMap, getErrorMessage } from 'ontime-utils';
import { sheets, type sheets_v4 } from '@googleapis/sheets';
import { Credentials, OAuth2Client } from 'google-auth-library';
import { logger } from '../../classes/Logger.js';
import { parseRundowns } from '../rundown/rundown.parser.js';
import { getCurrentRundown, getProjectCustomFields, processRundown } from '../rundown/rundown.dao.js';
import { parseExcel } from '../excel/excel.parser.js';
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
import { consoleSubdued } from '../../utils/console.js';
import { cellRequestFromEvent, type ClientSecret, getA1Notation, isClientSecret } from './sheets.utils.js';
import { catchCommonImportXlsxError } from './googleApi.utils.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.Timeout | 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();
}
/**
* Resets all state related to an eventual connection
*/
export function revoke(): ReturnType<typeof hasAuth> {
reset();
return hasAuth();
}
/**
* Parses and validates a client secret string
*/
export function handleClientSecret(clientSecret: string): ClientSecret {
const clientSecretObject = JSON.parse(clientSecret);
if (!isClientSecret(clientSecretObject)) {
throw new Error('Client secret is 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
*/
async function getDeviceCodes(clientSecret: ClientSecret): Promise<CodesResponse> {
const response = await fetch(codesUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
client_id: clientSecret.installed.client_id,
scope: sheetScope,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to fetch device codes: ${response.status} ${response.statusText} - ${errorText}`);
}
const deviceCodes: CodesResponse = await response.json();
return deviceCodes;
}
/**
* Gets credentials from Google Auth server
*/
function verifyConnection(
clientSecret: ClientSecret,
device_code: string,
interval: number,
expires_in: number,
postAction: () => Promise<any>,
) {
logger.info(LogOrigin.Server, 'Start polling for auth...');
// 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() {
try {
const response = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: clientSecret.installed.client_id,
client_secret: clientSecret.installed.client_secret,
device_code,
grant_type: grantType,
}),
});
// server returns 428 if user hasnt yet completed the auth process
if (response.status === 428) {
consoleSubdued('User not auth yet');
return;
}
if (!response.ok) {
logger.error(LogOrigin.Server, `Authentication poll failed with code: ${response.status}`);
return;
}
const auth: Credentials = await response.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;
}
await postAction();
} catch (error) {
logger.error(LogOrigin.Server, `Authentication poll error: ${(error as Error).message}`);
}
}
}
export function hasAuth(): { authenticated: AuthenticationStatus; sheetId: string } {
if (!currentSheetId) {
throw new Error('No sheet ID');
}
if (cleanupTimeout) {
return { authenticated: 'pending', sheetId: currentSheetId };
}
return { authenticated: currentAuthClient ? 'authenticated' : 'not_authenticated', sheetId: currentSheetId };
}
async function verifySheet(
sheetId = currentSheetId,
authClient = currentAuthClient,
): Promise<{ worksheetOptions: string[] }> {
if (!sheetId || !authClient) {
throw new Error('Missing sheet ID or authentication');
}
try {
const spreadsheets = await sheets({ version: 'v4', auth: authClient }).spreadsheets.get({
spreadsheetId: sheetId,
includeGridData: false,
});
const worksheets: string[] = [];
spreadsheets.data.sheets?.forEach((sheet) => {
if (sheet.properties?.title) {
worksheets.push(sheet.properties.title);
}
});
if (worksheets.length === 0) {
throw new Error('No worksheets found');
}
return { worksheetOptions: worksheets };
} catch (error) {
// attempt to catch errors caused by importing xlsx
catchCommonImportXlsxError(error);
const errorMessage = getErrorMessage(error);
throw new Error(`Failed to verify sheet: ${errorMessage}`);
}
}
export async function handleInitialConnection(
clientSecret: ClientSecret,
sheetId: string,
): Promise<{ verification_url: string; user_code: string }> {
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) {
if (!currentAuthUrl || !currentAuthCode) {
throw new Error('No ongoing connection');
}
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 }> {
if (!currentAuthClient) {
throw new Error('Not authenticated');
}
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}`);
}
if (!spreadsheets.data.sheets) {
throw new Error('No worksheets found');
}
const selectedWorksheet = spreadsheets.data.sheets.find(
(sheet) => sheet.properties?.title && sheet.properties.title.toLowerCase() === worksheet.toLowerCase(),
);
if (!selectedWorksheet) {
throw new Error('Could not find worksheet');
}
/*
The first spreadsheet provided by google sheet has an id = 0,
so !0 returns true, the only other number that returns true in this setup is NaN,
so if x !== 0 && x !== NaN, then !x returns false, we indeed want !NaN to return true,
but we would like !0 to return false, reason why is also checked that the id is not 0,
because if it is 0, then I should not enter the condition.
*/
if (
!selectedWorksheet.properties ||
(!selectedWorksheet.properties.sheetId && selectedWorksheet.properties.sheetId !== 0)
) {
throw new Error('Got invalid data from worksheet');
}
const endCell = getA1Notation(
selectedWorksheet.properties?.gridProperties?.rowCount ?? -1,
selectedWorksheet.properties?.gridProperties?.columnCount ?? -1,
);
return { worksheetId: selectedWorksheet.properties.sheetId, range: `${worksheet}!A1:${endCell}` };
}
export async function upload(sheetId: string, options: ImportMap) {
if (!currentAuthClient) {
throw new Error('Not authenticated');
}
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 || !readResponse.data.values) {
throw new Error(`Sheet read failed: ${readResponse.statusText}`);
}
const { sheetMetadata } = parseExcel(readResponse.data.values, getProjectCustomFields(), 'not-used', options);
const rundown = getCurrentRundown();
const sheetOrder: string[] = [];
let prevGroup: EntryId | null = null;
for (const id of rundown.flatOrder) {
const entry = rundown.entries[id];
if (isOntimeEvent(entry) || isOntimeMilestone(entry)) {
if (prevGroup && entry.parent === null) {
// if we were in a group and are now not insert a group end
sheetOrder.push(`group-end-${prevGroup}`);
}
prevGroup = entry.parent;
}
sheetOrder.push(entry.id);
}
const titleMetadata = Object.values(sheetMetadata)[0];
if (titleMetadata === undefined) {
throw new Error('Sheet read failed: failed to find title row');
}
const titleRow = titleMetadata['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 + sheetOrder.length,
sheetId: worksheetId,
},
},
});
try {
// update the corresponding row with event data
sheetOrder.forEach((entryId, index) => {
const isGroupEnd = entryId.startsWith('group-end-');
const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId;
const entry = isGroupEnd
? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup)
: structuredClone(rundown.entries[id]);
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata));
});
} catch (e) {
throw new Error(`Sheet write failed to correctly parse rundown: ${e}`);
}
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}`);
}
}
/**
* Imports a sheet as a rundown
* @throws if the client is not authenticated
* @throws if the response from Google Sheets fails
* @throws if the sheet does not contain any data
*/
export async function download(
sheetId: string,
options: ImportMap,
): Promise<{
rundown: Rundown;
customFields: CustomFields;
summary: RundownSummary;
}> {
if (!currentAuthClient) {
throw new Error('Not authenticated');
}
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}`);
}
if (!googleResponse.data.values) {
throw new Error('Sheet: No data found in the worksheet');
}
const dataFromSheet = parseExcel(googleResponse.data.values, getProjectCustomFields(), 'Rundown', options);
const rundownId = dataFromSheet.rundown.id;
const dataModel: Pick<DatabaseModel, 'rundowns' | 'customFields'> = {
rundowns: {
[rundownId]: dataFromSheet.rundown,
},
customFields: dataFromSheet.customFields,
};
const customFields = parseCustomFields(dataModel);
const parsedRundown = parseRundowns(dataModel, customFields);
const importedRundown = parsedRundown[rundownId];
if (!importedRundown) {
throw new Error(`Sheet: Rundown with ID ${rundownId} not found in the worksheet`);
}
if (importedRundown.order.length < 1) {
throw new Error('Sheet: Could not find data to import in the worksheet');
}
const processedRundown = processRundown(importedRundown, customFields);
return {
rundown: {
id: importedRundown.id,
title: importedRundown.title,
order: processedRundown.order,
flatOrder: processedRundown.flatEntryOrder,
entries: processedRundown.entries,
revision: 0,
},
summary: {
duration: processedRundown.totalDuration,
start: processedRundown.firstStart,
end: processedRundown.lastEnd,
},
customFields,
};
}
@@ -0,0 +1,217 @@
import {
OntimeEntry,
RGBColour,
isOntimeDelay,
OntimeEntryCommonKeys,
isOntimeGroup,
isOntimeMilestone,
} from 'ontime-types';
import { cssOrHexToColour, isLightColour, millisToString, mixColours } from 'ontime-utils';
import type { sheets_v4 } from '@googleapis/sheets';
import { is } from '../../utils/is.js';
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
* @throws
*/
export function isClientSecret(clientSecret: object): clientSecret is ClientSecret {
if (!('installed' in clientSecret)) {
return false;
}
const { installed } = clientSecret;
if (!is.object(installed)) {
return false;
}
// we expect client secret file to contain the following keys
return is.objectWithKeys(installed, [
'client_id',
'auth_uri',
'token_uri',
'token_uri',
'auth_provider_x509_cert_url',
'client_secret',
]);
}
/**
*
* @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 {OntimeEntry} entry
* @param {number} index - index of the event
* @param {number} worksheetId
* @param {object} metadata - object with all the cell positions of the title of each attribute
* @returns {sheets_v4.Schema} - list of update requests
*/
export function cellRequestFromEvent(
entry: OntimeEntry,
index: number,
worksheetId: number,
metadata: object,
): sheets_v4.Schema$Request {
const rowData = Object.entries(metadata) // check what headings are available in the sheet
.filter(([_, value]) => value !== undefined) // drop anything that is undefined
.sort(([_a, a], [_b, b]) => a['col'] - b['col']) as [
OntimeEntryCommonKeys | 'blank',
{ col: number; row: number },
][]; // sort the array by the column index
// inset blank data is there is spacing between relevant ontime columns
for (const [index, e] of rowData.entries()) {
if (index === 0) continue;
const prevCol = rowData[index - 1][1].col;
const thisCol = e[1].col;
const diff = thisCol - prevCol;
if (diff > 1) {
const fillArr = new Array<(typeof rowData)[0]>(1).fill(['blank', { row: e[1].row, col: prevCol + 1 }]);
rowData.splice(index, 0, ...fillArr);
}
}
const colours = 'colour' in entry ? getAccessibleColour(entry.colour) : undefined;
const cellColor: sheets_v4.Schema$CellData = !colours
? {}
: {
userEnteredFormat: {
backgroundColor: toSheetColourLevel(colours.background),
textFormat: {
foregroundColor: toSheetColourLevel(colours.text),
},
borders: {
bottom: {
style: 'SOLID',
color: toSheetColourLevel(colours.border),
},
},
},
};
const returnRows: sheets_v4.Schema$CellData[] = rowData.map(([key, _]) => {
return { ...getCellData(key, entry), ...cellColor };
});
const headerLocation = rowData[0][1];
return {
updateCells: {
start: {
sheetId: worksheetId,
rowIndex: index + headerLocation.row + 1,
columnIndex: headerLocation.col,
},
fields: 'userEnteredValue,userEnteredFormat',
rows: [
{
values: returnRows,
},
],
},
};
}
function getCellData(key: OntimeEntryCommonKeys | 'blank', entry: OntimeEntry) {
if (isOntimeDelay(entry) || key === 'blank') {
return {};
}
// we need to remap the event type to timer type in the case of groups and milestones
if (key === 'timerType') {
if (isOntimeGroup(entry))
return { userEnteredValue: { stringValue: entry.id.startsWith('group-end') ? 'group-end' : 'group' } };
if (isOntimeMilestone(entry)) return { userEnteredValue: { stringValue: 'milestone' } };
return { userEnteredValue: { stringValue: entry.timerType } };
}
// all other data is not relevant for the group end entry
if (entry.id.startsWith('group-end')) {
return {};
}
// we need to flatten the milestones
if (key.startsWith('custom')) {
const customKey = key.split(':')[1];
return { userEnteredValue: { stringValue: entry.custom[customKey] } };
}
// typescript cannot guarantee that the key exists for every entry
// so we check for the key existence and assert the type
if (!(key in entry)) return {};
const value = entry[key as keyof OntimeEntry];
if (typeof value === 'number') {
return { userEnteredValue: { stringValue: millisToString(value) } };
}
if (typeof value === 'string') {
return { userEnteredValue: { stringValue: value } };
}
if (typeof value === 'boolean') {
return { userEnteredValue: { boolValue: value } };
}
}
type googleSheetCellColour = {
background: RGBColour;
text: RGBColour;
border: RGBColour;
};
function getAccessibleColour(bgColour?: string): googleSheetCellColour | undefined {
if (!bgColour) return undefined;
const background = cssOrHexToColour(bgColour);
if (!background) return undefined;
const text = isLightColour(background) ? BLACK : WHITE;
const border = mixColours(background, text, 0.2);
return { background, text, border };
}
const BLACK: RGBColour = { red: 0, green: 0, blue: 0, alpha: 1 };
const WHITE: RGBColour = { red: 255, green: 255, blue: 255, alpha: 0.98 };
// sheets use color values from 0 to 1
function toSheetColourLevel(colour: RGBColour): RGBColour {
return { red: colour.red / 255, green: colour.green / 255, blue: colour.blue / 255, alpha: colour.alpha };
}