mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
refactor: extract metadata from sheet
This commit is contained in:
committed by
Carlos Valente
parent
37dc9a620d
commit
42dcf35f45
@@ -1,5 +1,5 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { CustomFields, Rundown, RundownSummary } from 'ontime-types';
|
||||
import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata, SpreadsheetWorksheetOptions } from 'ontime-types';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
@@ -11,12 +11,12 @@ const excelPath = `${apiEntryUrl}/excel`;
|
||||
|
||||
/**
|
||||
* upload Excel file to server
|
||||
* @return string - file ID op the uploaded file
|
||||
* Uploads an Excel file and returns worksheet names plus metadata for the initial worksheet.
|
||||
*/
|
||||
export async function upload(file: File, requestOptions?: RequestOptions): Promise<string[]> {
|
||||
export async function upload(file: File, requestOptions?: RequestOptions): Promise<SpreadsheetWorksheetOptions> {
|
||||
const formData = new FormData();
|
||||
formData.append('excel', file);
|
||||
const response = await axios.post(`${excelPath}/upload`, formData, {
|
||||
const response: AxiosResponse<SpreadsheetWorksheetOptions> = await axios.post(`${excelPath}/upload`, formData, {
|
||||
signal: requestOptions?.signal,
|
||||
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||
headers: {
|
||||
@@ -26,16 +26,11 @@ export async function upload(file: File, requestOptions?: RequestOptions): Promi
|
||||
return response.data;
|
||||
}
|
||||
|
||||
type PreviewSpreadsheetResponse = {
|
||||
rundown: Rundown;
|
||||
customFields: CustomFields;
|
||||
summary: RundownSummary;
|
||||
};
|
||||
export async function importRundownPreview(
|
||||
options: ImportMap,
|
||||
requestOptions?: RequestOptions,
|
||||
): Promise<PreviewSpreadsheetResponse> {
|
||||
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(
|
||||
): Promise<SpreadsheetPreviewResponse> {
|
||||
const response: AxiosResponse<SpreadsheetPreviewResponse> = await axios.post(
|
||||
`${excelPath}/preview`,
|
||||
{
|
||||
options,
|
||||
@@ -48,6 +43,22 @@ export async function importRundownPreview(
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getWorksheetMetadata(
|
||||
worksheet: string,
|
||||
requestOptions?: RequestOptions,
|
||||
): Promise<SpreadsheetWorksheetMetadata> {
|
||||
const response: AxiosResponse<SpreadsheetWorksheetMetadata> = await axios.post(
|
||||
`${excelPath}/metadata`,
|
||||
{ worksheet },
|
||||
{
|
||||
signal: requestOptions?.signal,
|
||||
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||
},
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a xlsx representation of the rundown from the server
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { AuthenticationStatus, CustomFields, Rundown, RundownSummary } from 'ontime-types';
|
||||
import type {
|
||||
AuthenticationStatus,
|
||||
SpreadsheetPreviewResponse,
|
||||
SpreadsheetWorksheetMetadata,
|
||||
SpreadsheetWorksheetOptions,
|
||||
} from 'ontime-types';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
@@ -59,11 +64,7 @@ export const previewRundown = async (
|
||||
sheetId: string,
|
||||
options: ImportMap,
|
||||
requestOptions?: RequestOptions,
|
||||
): Promise<{
|
||||
rundown: Rundown;
|
||||
customFields: CustomFields;
|
||||
summary: RundownSummary;
|
||||
}> => {
|
||||
): Promise<SpreadsheetPreviewResponse> => {
|
||||
const response = await axios.post(
|
||||
`${sheetsPath}/${sheetId}/read`,
|
||||
{ options },
|
||||
@@ -75,11 +76,34 @@ export const previewRundown = async (
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getWorksheetNames = async (sheetId: string, requestOptions?: RequestOptions): Promise<string[]> => {
|
||||
const response: AxiosResponse<string[]> = await axios.post(`${sheetsPath}/${sheetId}/worksheets`, undefined, {
|
||||
signal: requestOptions?.signal,
|
||||
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||
});
|
||||
export const getWorksheetMetadata = async (
|
||||
sheetId: string,
|
||||
worksheet: string,
|
||||
requestOptions?: RequestOptions,
|
||||
): Promise<SpreadsheetWorksheetMetadata> => {
|
||||
const response: AxiosResponse<SpreadsheetWorksheetMetadata> = await axios.post(
|
||||
`${sheetsPath}/${sheetId}/metadata`,
|
||||
{ worksheet },
|
||||
{
|
||||
signal: requestOptions?.signal,
|
||||
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getWorksheetNames = async (
|
||||
sheetId: string,
|
||||
requestOptions?: RequestOptions,
|
||||
): Promise<SpreadsheetWorksheetOptions> => {
|
||||
const response: AxiosResponse<SpreadsheetWorksheetOptions> = await axios.post(
|
||||
`${sheetsPath}/${sheetId}/worksheets`,
|
||||
undefined,
|
||||
{
|
||||
signal: requestOptions?.signal,
|
||||
timeout: requestOptions?.timeout ?? axiosConfig.longTimeout,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getWorksheetMetadataFromRows } from '../spreadsheetMetadata.utils.js';
|
||||
|
||||
describe('getWorksheetMetadataFromRows()', () => {
|
||||
it('detects headers from the first meaningful row', () => {
|
||||
const result = getWorksheetMetadataFromRows('Sheet 1', [
|
||||
[''],
|
||||
['Cue', 'Title', 'Duration'],
|
||||
['1', 'Opening', '00:05:00'],
|
||||
['2', 'Intro', '00:02:00'],
|
||||
]);
|
||||
|
||||
expect(result.headers).toEqual(['Cue', 'Title', 'Duration']);
|
||||
});
|
||||
|
||||
it('throws when no worksheet headers can be found', () => {
|
||||
expect(() => getWorksheetMetadataFromRows('Empty', [[''], ['']])).toThrow('Could not find any data in worksheet');
|
||||
});
|
||||
|
||||
it('falls back to a single non-empty cell as the header row', () => {
|
||||
const result = getWorksheetMetadataFromRows('Sheet', [['Title'], ['Opening'], ['Closing']]);
|
||||
|
||||
expect(result.headers).toEqual(['Title']);
|
||||
});
|
||||
|
||||
it('normalizes numeric and mixed-type cell values to strings', () => {
|
||||
const result = getWorksheetMetadataFromRows('Sheet', [
|
||||
[42, true, 'Name', 'Value'],
|
||||
['a', 'b'],
|
||||
]);
|
||||
|
||||
expect(result.headers).toEqual(['42', 'true', 'Name', 'Value']);
|
||||
});
|
||||
|
||||
it('filters sparse columns where headers are empty', () => {
|
||||
const result = getWorksheetMetadataFromRows('Sheet', [
|
||||
['Cue', '', 'Title', '', 'Duration', 'Note'],
|
||||
['1', '', 'Opening', '', '', ''],
|
||||
['2', '', 'Closing', '', '', ''],
|
||||
]);
|
||||
|
||||
expect(result.headers).toEqual(['Cue', 'Title', 'Duration', 'Note']);
|
||||
});
|
||||
|
||||
it('uses progressive threshold to find row with most columns', () => {
|
||||
// First row has 2 columns, second row has 6 - should pick the second row as it's more likely to be a header
|
||||
const result = getWorksheetMetadataFromRows('Sheet', [
|
||||
['', ''],
|
||||
['ID', 'Cue', 'Title', 'Start', 'Duration', 'Note'],
|
||||
['1', '1.0', 'Opening', '10:00', '5:00', 'Test'],
|
||||
['2', '2.0', 'Closing', '10:05', '3:00', 'Test2'],
|
||||
]);
|
||||
|
||||
expect(result.headers).toEqual(['ID', 'Cue', 'Title', 'Start', 'Duration', 'Note']);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,23 @@
|
||||
import express from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
import { CustomFields, ErrorResponse, Rundown, RundownSummary } from 'ontime-types';
|
||||
import type {
|
||||
ErrorResponse,
|
||||
SpreadsheetPreviewResponse,
|
||||
SpreadsheetWorksheetMetadata,
|
||||
SpreadsheetWorksheetOptions,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
|
||||
import { EXCEL_MIME } from './excel.constants.js';
|
||||
import { uploadExcel } from './excel.middleware.js';
|
||||
import { generateExcelFile, generateRundownPreview, readExcelFile } from './excel.service.js';
|
||||
import { validateFileExists, validateImportMapOptions, validateRundownExport } from './excel.validation.js';
|
||||
import { generateExcelFile, generateRundownPreview, getWorksheetMetadata, readExcelFile } from './excel.service.js';
|
||||
import {
|
||||
validateFileExists,
|
||||
validateImportMapOptions,
|
||||
validateRundownExport,
|
||||
validateWorksheetMetadataRequest,
|
||||
} from './excel.validation.js';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
@@ -15,12 +25,12 @@ router.post(
|
||||
'/upload',
|
||||
uploadExcel,
|
||||
validateFileExists,
|
||||
async (req: Request, res: Response<string[] | ErrorResponse>) => {
|
||||
async (req: Request, res: Response<SpreadsheetWorksheetOptions | ErrorResponse>) => {
|
||||
try {
|
||||
// file has been validated by middleware
|
||||
const filePath = (req.file as Express.Multer.File).path;
|
||||
const worksheetNames = await readExcelFile(filePath);
|
||||
res.status(200).send(worksheetNames);
|
||||
const worksheetOptions = await readExcelFile(filePath);
|
||||
res.status(200).send(worksheetOptions);
|
||||
} catch (error) {
|
||||
res.status(500).send({ message: String(error) });
|
||||
}
|
||||
@@ -30,10 +40,7 @@ router.post(
|
||||
router.post(
|
||||
'/preview',
|
||||
validateImportMapOptions,
|
||||
(
|
||||
req: Request,
|
||||
res: Response<{ rundown: Rundown; customFields: CustomFields; summary: RundownSummary } | ErrorResponse>,
|
||||
) => {
|
||||
(req: Request, res: Response<SpreadsheetPreviewResponse | ErrorResponse>) => {
|
||||
try {
|
||||
const { options } = req.body;
|
||||
const data = generateRundownPreview(options);
|
||||
@@ -44,6 +51,20 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/metadata',
|
||||
validateWorksheetMetadataRequest,
|
||||
(req: Request, res: Response<SpreadsheetWorksheetMetadata | ErrorResponse>) => {
|
||||
try {
|
||||
const { worksheet } = req.body;
|
||||
const data = getWorksheetMetadata(worksheet);
|
||||
res.status(200).send(data);
|
||||
} catch (error) {
|
||||
res.status(500).send({ message: String(error) });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.get('/:rundownId/export', validateRundownExport, (req: Request, res: Response) => {
|
||||
try {
|
||||
const rundown = getDataProvider().getRundown(req.params.rundownId);
|
||||
|
||||
@@ -16,6 +16,9 @@ import { parseCustomFields } from '../custom-fields/customFields.parser.js';
|
||||
import { getProjectCustomFields, processRundown } from '../rundown/rundown.dao.js';
|
||||
import { parseRundown } from '../rundown/rundown.parser.js';
|
||||
import { parseExcel } from './excel.parser.js';
|
||||
import type { SpreadsheetWorksheetMetadata } from 'ontime-types';
|
||||
|
||||
import { getWorksheetMetadataFromRows } from './spreadsheetMetadata.utils.js';
|
||||
import { rundownToTabular } from './excel.utils.js';
|
||||
|
||||
// we keep the excel data in memory to allow the flow upload -> preview
|
||||
@@ -33,11 +36,25 @@ function getValidWorksheetName(title: string): string {
|
||||
return truncatedTitle.length > 0 ? truncatedTitle : 'Rundown';
|
||||
}
|
||||
|
||||
function getInitialWorksheetMetadata(worksheets: string[]): SpreadsheetWorksheetMetadata | null {
|
||||
for (const worksheet of worksheets) {
|
||||
try {
|
||||
return getWorksheetMetadata(worksheet);
|
||||
} catch {
|
||||
// Continue looking for the first worksheet with usable headers.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives and parses an excel file
|
||||
* The file is deleted after being read
|
||||
*/
|
||||
export async function readExcelFile(filePath: string): Promise<string[]> {
|
||||
export async function readExcelFile(
|
||||
filePath: string,
|
||||
): Promise<{ worksheets: string[]; metadata: SpreadsheetWorksheetMetadata | null }> {
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error('Upload of excel file failed');
|
||||
}
|
||||
@@ -50,7 +67,13 @@ export async function readExcelFile(filePath: string): Promise<string[]> {
|
||||
|
||||
await deleteFile(filePath);
|
||||
|
||||
return excelData.SheetNames;
|
||||
const worksheets = excelData.SheetNames;
|
||||
const metadata = getInitialWorksheetMetadata(worksheets);
|
||||
|
||||
return {
|
||||
worksheets,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
export function generateRundownPreview(options: ImportMap): {
|
||||
@@ -94,6 +117,17 @@ export function generateRundownPreview(options: ImportMap): {
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorksheetMetadata(worksheet: string) {
|
||||
const data = excelData.Sheets[worksheet];
|
||||
|
||||
if (!data) {
|
||||
throw new Error(`Could not find worksheet: ${worksheet}`);
|
||||
}
|
||||
|
||||
const arrayOfData: unknown[][] = xlsx.utils.sheet_to_json(data, { header: 1, blankrows: false, raw: false });
|
||||
return getWorksheetMetadataFromRows(worksheet, arrayOfData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an xlsx file from a given rundown and custom fields
|
||||
* @throws if the rundown is empty
|
||||
|
||||
@@ -18,4 +18,9 @@ export const validateImportMapOptions = [
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
export const validateWorksheetMetadataRequest = [
|
||||
body('worksheet').isString().trim().notEmpty(),
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
export const validateRundownExport = [param('rundownId').isString().trim().notEmpty(), requestValidationFunction];
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { SpreadsheetWorksheetMetadata } from 'ontime-types';
|
||||
|
||||
function normalizeCell(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function countNonEmpty(row: string[]): number {
|
||||
return row.filter((cell) => cell.length > 0).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the index of the header row in a spreadsheet.
|
||||
* Prioritizes rows with >5 non-empty columns (early exit), otherwise returns the row with the most columns.
|
||||
* This heuristic works because header rows typically have more columns than data rows.
|
||||
* @param rows - Array of spreadsheet rows, each containing normalized cell values
|
||||
* @returns Index of the header row, or -1 if no data is found
|
||||
*/
|
||||
function findHeaderRowIndex(rows: string[][]): number {
|
||||
const rowCounts = rows.map(countNonEmpty);
|
||||
|
||||
let bestIndex = -1;
|
||||
let bestCount = 0;
|
||||
|
||||
for (let index = 0; index < rowCounts.length; index++) {
|
||||
const count = rowCounts[index];
|
||||
|
||||
// Early exit if we find a row with >5 columns (likely a header)
|
||||
if (count > 5) {
|
||||
return index;
|
||||
}
|
||||
|
||||
// Otherwise track the row with the most columns
|
||||
if (count > bestCount) {
|
||||
bestIndex = index;
|
||||
bestCount = count;
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts metadata from a spreadsheet worksheet including detected headers.
|
||||
* Normalizes cell values and identifies the header row.
|
||||
* @throws Error if no data is found or headers cannot be detected
|
||||
*/
|
||||
export function getWorksheetMetadataFromRows(worksheet: string, sheetRows: unknown[][]): SpreadsheetWorksheetMetadata {
|
||||
const rows = sheetRows.map((row) => row.map(normalizeCell));
|
||||
const headerRowIndex = findHeaderRowIndex(rows);
|
||||
|
||||
if (headerRowIndex === -1) {
|
||||
throw new Error(`Could not find any data in worksheet: ${worksheet}`);
|
||||
}
|
||||
|
||||
const headerEntries = rows[headerRowIndex]
|
||||
.map((header, index) => ({ header, index }))
|
||||
.filter(({ header }) => header.length > 0);
|
||||
|
||||
if (headerEntries.length === 0) {
|
||||
throw new Error(`Could not detect worksheet headers in: ${worksheet}`);
|
||||
}
|
||||
|
||||
return {
|
||||
worksheet,
|
||||
headers: headerEntries.map(({ header }) => header),
|
||||
};
|
||||
}
|
||||
@@ -6,12 +6,19 @@
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import type { AuthenticationStatus, CustomFields, ErrorResponse, Rundown, RundownSummary } from 'ontime-types';
|
||||
import type {
|
||||
AuthenticationStatus,
|
||||
ErrorResponse,
|
||||
SpreadsheetPreviewResponse,
|
||||
SpreadsheetWorksheetMetadata,
|
||||
SpreadsheetWorksheetOptions,
|
||||
} from 'ontime-types';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import { deleteFile } from '../../utils/fileManagement.js';
|
||||
import {
|
||||
download,
|
||||
getWorksheetMetadata,
|
||||
getWorksheetOptions,
|
||||
handleClientSecret,
|
||||
handleInitialConnection,
|
||||
@@ -69,10 +76,13 @@ export async function revokeAuthentication(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorksheetNamesFromSheet(req: Request, res: Response<string[] | ErrorResponse>) {
|
||||
export async function getWorksheetNamesFromSheet(
|
||||
req: Request,
|
||||
res: Response<SpreadsheetWorksheetOptions | ErrorResponse>,
|
||||
) {
|
||||
try {
|
||||
const { sheetId } = req.params;
|
||||
const { worksheetOptions } = await getWorksheetOptions(sheetId);
|
||||
const worksheetOptions = await getWorksheetOptions(sheetId);
|
||||
res.status(200).send(worksheetOptions);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
@@ -80,16 +90,21 @@ export async function getWorksheetNamesFromSheet(req: Request, res: Response<str
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorksheetMetadataFromSheet(req: Request, res: Response<SpreadsheetWorksheetMetadata | ErrorResponse>) {
|
||||
try {
|
||||
const { sheetId } = req.params;
|
||||
const { worksheet } = req.body;
|
||||
const metadata = await getWorksheetMetadata(sheetId, worksheet);
|
||||
res.status(200).send(metadata);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(500).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function readFromSheet(
|
||||
req: Request,
|
||||
res: Response<
|
||||
| {
|
||||
rundown: Rundown;
|
||||
customFields: CustomFields;
|
||||
summary: RundownSummary;
|
||||
}
|
||||
| ErrorResponse
|
||||
>,
|
||||
res: Response<SpreadsheetPreviewResponse | ErrorResponse>,
|
||||
) {
|
||||
try {
|
||||
const { sheetId } = req.params;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import express from 'express';
|
||||
|
||||
import {
|
||||
getWorksheetMetadataFromSheet,
|
||||
getWorksheetNamesFromSheet,
|
||||
readFromSheet,
|
||||
requestConnection,
|
||||
@@ -13,7 +14,12 @@ import {
|
||||
writeToSheet,
|
||||
} from './sheets.controller.js';
|
||||
import { uploadClientSecret } from './sheets.middleware.js';
|
||||
import { validateRequestConnection, validateSheetId, validateSheetOptions } from './sheets.validation.js';
|
||||
import {
|
||||
validateRequestConnection,
|
||||
validateSheetId,
|
||||
validateSheetOptions,
|
||||
validateWorksheetMetadata,
|
||||
} from './sheets.validation.js';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
@@ -23,6 +29,7 @@ router.post('/:sheetId/connect', uploadClientSecret, validateRequestConnection,
|
||||
router.post('/revoke', revokeAuthentication);
|
||||
|
||||
router.post('/:sheetId/worksheets', validateSheetId, getWorksheetNamesFromSheet);
|
||||
router.post('/:sheetId/metadata', validateWorksheetMetadata, getWorksheetMetadataFromSheet);
|
||||
|
||||
router.post('/:sheetId/read', validateSheetOptions, readFromSheet);
|
||||
router.post('/:sheetId/write', validateSheetOptions, writeToSheet);
|
||||
|
||||
@@ -26,6 +26,9 @@ import { logger } from '../../classes/Logger.js';
|
||||
import { consoleSubdued } from '../../utils/console.js';
|
||||
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
|
||||
import { parseExcel } from '../excel/excel.parser.js';
|
||||
import type { SpreadsheetWorksheetMetadata } from 'ontime-types';
|
||||
|
||||
import { getWorksheetMetadataFromRows } from '../excel/spreadsheetMetadata.utils.js';
|
||||
import { getCurrentRundown, getProjectCustomFields, processRundown } from '../rundown/rundown.dao.js';
|
||||
import { parseRundowns } from '../rundown/rundown.parser.js';
|
||||
import { catchCommonImportXlsxError } from './googleApi.utils.js';
|
||||
@@ -224,7 +227,7 @@ export function hasAuth(): { authenticated: AuthenticationStatus; sheetId: strin
|
||||
async function verifySheet(
|
||||
sheetId = currentSheetId,
|
||||
authClient = currentAuthClient,
|
||||
): Promise<{ worksheetOptions: string[] }> {
|
||||
): Promise<string[]> {
|
||||
if (!sheetId || !authClient) {
|
||||
throw new Error('Missing sheet ID or authentication');
|
||||
}
|
||||
@@ -245,7 +248,7 @@ async function verifySheet(
|
||||
if (worksheets.length === 0) {
|
||||
throw new Error('No worksheets found');
|
||||
}
|
||||
return { worksheetOptions: worksheets };
|
||||
return worksheets;
|
||||
} catch (error) {
|
||||
// attempt to catch errors caused by importing xlsx
|
||||
catchCommonImportXlsxError(error);
|
||||
@@ -284,13 +287,61 @@ export async function handleInitialConnection(
|
||||
* Allow calling verification for sheetId
|
||||
* @returns
|
||||
*/
|
||||
export async function getWorksheetOptions(sheetId: string): ReturnType<typeof verifySheet> {
|
||||
export async function getWorksheetOptions(
|
||||
sheetId: string,
|
||||
): Promise<{ worksheets: string[]; metadata: SpreadsheetWorksheetMetadata | null }> {
|
||||
if (!currentAuthClient) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
currentSheetId = sheetId;
|
||||
|
||||
return verifySheet(sheetId);
|
||||
const worksheets = await verifySheet(sheetId);
|
||||
const metadata = await getInitialWorksheetMetadata(sheetId, worksheets);
|
||||
|
||||
return {
|
||||
worksheets,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
async function getInitialWorksheetMetadata(
|
||||
sheetId: string,
|
||||
worksheets: string[],
|
||||
): Promise<SpreadsheetWorksheetMetadata | null> {
|
||||
for (const worksheet of worksheets) {
|
||||
try {
|
||||
return await getWorksheetMetadata(sheetId, worksheet);
|
||||
} catch {
|
||||
// Continue looking for the first worksheet with usable headers.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getWorksheetMetadata(sheetId: string, worksheet: string) {
|
||||
if (!currentAuthClient) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const { range } = await verifyWorksheet(sheetId, 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');
|
||||
}
|
||||
|
||||
return getWorksheetMetadataFromRows(worksheet, googleResponse.data.values);
|
||||
}
|
||||
|
||||
async function verifyWorksheet(sheetId: string, worksheet: string): Promise<{ worksheetId: number; range: string }> {
|
||||
|
||||
@@ -30,6 +30,12 @@ export const validateRequestConnection = [
|
||||
|
||||
export const validateSheetId = [param('sheetId').isString().trim().notEmpty(), requestValidationFunction];
|
||||
|
||||
export const validateWorksheetMetadata = [
|
||||
param('sheetId').isString().trim().notEmpty(),
|
||||
body('worksheet').isString().trim().notEmpty(),
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
export const validateSheetOptions = [
|
||||
param('sheetId').isString().trim().notEmpty(),
|
||||
body('options')
|
||||
|
||||
Reference in New Issue
Block a user