mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-10 18:03:47 +00:00
refactor: extract metadata from sheet
This commit is contained in:
committed by
Carlos Valente
parent
37dc9a620d
commit
42dcf35f45
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user