refactor: extract metadata from sheet

This commit is contained in:
Carlos Valente
2026-03-26 21:30:25 +01:00
committed by Carlos Valente
parent 37dc9a620d
commit 42dcf35f45
11 changed files with 356 additions and 50 deletions
@@ -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']);
});
});
+31 -10
View File
@@ -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),
};
}