mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-13 11:23:50 +00:00
chore: cleanup directory structure
This commit is contained in:
committed by
Carlos Valente
parent
e6070e6efd
commit
b75b69c3b1
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user