feat: export excel file

refactor: align id columns between export and import

refactor: match column names with template
This commit is contained in:
Carlos Valente
2025-10-07 22:05:37 +02:00
committed by Carlos Valente
parent 10762f10bf
commit 1d8c04d0b4
16 changed files with 672 additions and 42 deletions
@@ -0,0 +1,363 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`rundownToTabular() > returns an array of arrays describing a rundown 1`] = `
[
[
"ID",
"Flag",
"Cue",
"Title",
"Colour",
"Time Start",
"Time End",
"Duration",
"Link Start",
"Count to end",
"Note",
"Timer Type",
"End Action",
"Warning Time",
"Danger Time",
"Skip",
"Custom Field",
"Images",
],
[
"e2163f",
"",
"Demo",
"Clear all, or Create New Project to start fresh",
"#9d9d9d",
"",
"",
"",
"",
"",
"Go to Settings cog wheel top left of Editor, Project > Create",
"milestone",
"",
"",
"",
"",
"Create Custom Fields to store specific text information or images.
Go to Settings > Project data > Custom fields",
"https://www.getontime.no/images/icons/ontime-logo.png",
],
[
"7eaf99",
"",
"",
"Morning Sessions",
"#339E4E",
"10:00:00",
"12:00:00",
"",
"",
"",
"",
"group",
"",
"",
"",
"",
"",
"",
],
[
"9bf60f",
"",
"1",
"Pre-show Countdown",
"#77C785",
"10:00:00",
"11:00:00",
"01:00:00",
"",
"",
"Music plays, holding slide on screens",
"count-down",
"",
"00:10:00",
"00:05:00",
"",
"Put additional info here",
"",
],
[
"bf71a2",
"",
"Standby",
"10:45 - Presenters ready side stage",
"#A790F5",
"",
"",
"",
"",
"",
"",
"milestone",
"",
"",
"",
"",
"",
"",
],
[
"c2697f",
"",
"1.1",
"Welcome",
"#FFCC78",
"11:00:00",
"11:10:00",
"00:10:00",
"TRUE",
"",
"Emma Thompson",
"count-down",
"",
"00:02:00",
"00:01:00",
"",
"",
"",
],
[
"fa593e",
"TRUE",
"1.2",
"Session 1",
"#77C785",
"11:10:00",
"12:00:00",
"00:50:00",
"TRUE",
"",
"Liam Carter, Sophia Patel + PowerPoint",
"count-down",
"",
"00:02:00",
"00:01:00",
"",
"",
"",
],
[
"a8b0b3",
"",
"House",
"11:30 - House staff setup lunch in lobby",
"#A790F5",
"",
"",
"",
"",
"",
"",
"milestone",
"",
"",
"",
"",
"",
"",
],
[
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"group-end",
"",
"",
"",
"",
],
[
"f60403",
"",
"",
"Lunch",
"#3E75E8",
"12:00:00",
"13:00:00",
"",
"",
"",
"",
"group",
"",
"",
"",
"",
"",
"",
],
[
"0aaa7d",
"",
"2.1",
"Lunch / Countdown to next session",
"#779BE7",
"12:00:00",
"13:00:00",
"01:00:00",
"TRUE",
"",
"Buffet in lobby
Music plays, holding slide on screens",
"count-down",
"",
"00:02:00",
"00:01:00",
"",
"",
"",
],
[
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"group-end",
"",
"",
"",
"",
],
[
"6b0edb",
"",
"",
"Afternoon Sessions",
"#339E4E",
"13:00:00",
"14:00:00",
"",
"",
"",
"",
"group",
"",
"",
"",
"",
"",
"",
],
[
"02afca",
"",
"Standby",
"12:45 - Presenters ready side stage",
"#A790F5",
"",
"",
"",
"",
"",
"",
"milestone",
"",
"",
"",
"",
"",
"",
],
[
"75ce86",
"",
"3.1",
"Session 2",
"#77C785",
"13:00:00",
"13:50:00",
"00:50:00",
"TRUE",
"",
"Ethan Brooks + PowerPoint + Video playback",
"count-down",
"",
"00:02:00",
"00:01:00",
"",
"",
"",
],
[
"e10ed9",
"",
"3.2",
"Wrap up",
"#FFCC78",
"13:50:00",
"14:00:00",
"00:10:00",
"",
"",
"Lucas Bennett",
"count-down",
"",
"00:02:00",
"00:01:00",
"",
"",
"",
],
[
"07df89",
"",
"Strike",
"14:00 - AV & Room strike",
"#A790F5",
"",
"",
"",
"",
"",
"",
"milestone",
"",
"",
"",
"",
"",
"",
],
[
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"group-end",
"",
"",
"",
"",
],
]
`;
@@ -1,11 +1,13 @@
import { CustomFields } from 'ontime-types';
import { defaultImportMap, ImportMap } from 'ontime-utils';
import { getCustomFieldData } from '../excel.utils.js';
import { demoDb } from '../../../models/demoProject.js';
import { getCustomFieldData, rundownToTabular } from '../excel.utils.js';
describe('getCustomFieldData()', () => {
it('generates a list of keys from the given import map', () => {
const importMap = {
const importMap: ImportMap = {
worksheet: 'event schedule',
timeStart: 'time start',
linkStart: 'link start',
@@ -27,8 +29,8 @@ describe('getCustomFieldData()', () => {
sound: 'sound',
video: 'av',
},
entryId: 'id',
} as ImportMap;
id: 'id',
};
const result = getCustomFieldData(importMap, {});
expect(result.mergedCustomFields).toStrictEqual({
@@ -58,7 +60,7 @@ describe('getCustomFieldData()', () => {
});
it('keeps colour information from existing fields', () => {
const importMap = {
const importMap: ImportMap = {
worksheet: 'event schedule',
timeStart: 'time start',
linkStart: 'link start',
@@ -81,8 +83,8 @@ describe('getCustomFieldData()', () => {
video: 'av',
'ontime key': 'excel label',
},
entryId: 'id',
} as ImportMap;
id: 'id',
};
const existingCustomFields: CustomFields = {
lighting: { label: 'lighting', type: 'text', colour: 'red' },
@@ -160,3 +162,13 @@ describe('getCustomFieldData()', () => {
});
});
});
describe('rundownToTabular()', () => {
it('returns an array of arrays describing a rundown', () => {
const testRundown = demoDb.rundowns['default'];
const testCustomFields = demoDb.customFields;
const result = rundownToTabular(testRundown, testCustomFields);
expect(result).toMatchSnapshot();
});
});
@@ -0,0 +1 @@
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
@@ -3,7 +3,7 @@ import multer, { type FileFilterCallback } from 'multer';
import { storage } from '../../utils/upload.js';
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
import { EXCEL_MIME } from './excel.constants.js';
const filterExcel = (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
if (file.mimetype.includes(EXCEL_MIME)) {
@@ -132,7 +132,7 @@ export const parseExcel = (
entry.timeDanger = parseExcelDate(column);
} else if (j === indexMap.colour) {
entry.colour = makeString(column, '');
} else if (j === indexMap.entryId) {
} else if (j === indexMap.id) {
entry.id = encodeURIComponent(makeString(column, undefined));
} else if (j in indexMap.custom) {
const importKey = indexMap.custom[j];
+24 -4
View File
@@ -1,9 +1,14 @@
import express from 'express';
import type { Request, Response } from 'express';
import { uploadExcel } from './excel.middleware.js';
import { validateFileExists, validateImportMapOptions } from './excel.validation.js';
import { CustomFields, ErrorResponse, Rundown } from 'ontime-types';
import { generateRundownPreview, listWorksheets, saveExcelFile } from './excel.service.js';
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
import { uploadExcel } from './excel.middleware.js';
import { validateFileExists, validateImportMapOptions, validateRundownExport } from './excel.validation.js';
import { generateExcelFile, generateRundownPreview, listWorksheets, readExcelFile } from './excel.service.js';
import { EXCEL_MIME } from './excel.constants.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export const router = express.Router();
@@ -11,7 +16,7 @@ router.post('/upload', uploadExcel, validateFileExists, async (req: Request, res
try {
// file has been validated by middleware
const filePath = (req.file as Express.Multer.File).path;
await saveExcelFile(filePath);
await readExcelFile(filePath);
res.status(201).send();
} catch (error) {
res.status(500).send({ message: String(error) });
@@ -40,3 +45,18 @@ router.post(
}
},
);
router.get('/:rundownId/export', validateRundownExport, (req: Request, res: Response) => {
try {
const rundown = getDataProvider().getRundown(req.params.rundownId);
const customFields = getProjectCustomFields();
const buffer = generateExcelFile(rundown, customFields);
res.setHeader('Content-Disposition', `attachment;`);
res.setHeader('Content-Type', EXCEL_MIME);
res.setHeader('Content-Length', buffer.length.toString());
res.status(200).send(buffer);
} catch (error) {
res.status(500).send({ message: 'Failed to generate Excel file' });
}
});
@@ -18,10 +18,15 @@ import { getProjectCustomFields } from '../rundown/rundown.dao.js';
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
import { parseExcel } from './excel.parser.js';
import { rundownToTabular } from './excel.utils.js';
let excelData: WorkBook = xlsx.utils.book_new();
export async function saveExcelFile(filePath: string) {
/**
* Receives and parses an excel file
* The file is deleted after being read
*/
export async function readExcelFile(filePath: string) {
if (!existsSync(filePath)) {
throw new Error('Upload of excel file failed');
}
@@ -33,6 +38,9 @@ export async function saveExcelFile(filePath: string) {
await deleteFile(filePath);
}
/**
* List all worksheets in the current spreadsheet file
*/
export function listWorksheets(): string[] {
return excelData.SheetNames;
}
@@ -60,3 +68,19 @@ export function generateRundownPreview(options: ImportMap): { rundown: Rundown;
return { rundown, customFields: parsedCustomFields };
}
/**
* Creates an xlsx file from a given rundown and custom fields
* @throws if the rundown is empty
*/
export function generateExcelFile(rundown: Rundown, customFields: CustomFields): Buffer {
if (rundown.order.length === 0) {
throw new Error('Cannot generate an Excel file from an empty rundown');
}
const workbook = xlsx.utils.book_new();
const worksheet = xlsx.utils.aoa_to_sheet(rundownToTabular(rundown, customFields));
xlsx.utils.book_append_sheet(workbook, worksheet, rundown.title || 'Rundown');
return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
}
+173 -5
View File
@@ -1,5 +1,5 @@
import { CustomFieldKey, CustomFields, MaybeNumber } from 'ontime-types';
import { checkRegex, customFieldLabelToKey, ImportMap } from 'ontime-utils';
import { CustomFieldKey, CustomFields, MaybeNumber, OntimeEntry, Rundown } from 'ontime-types';
import { checkRegex, customFieldLabelToKey, ImportMap, millisToString } from 'ontime-utils';
/**
* Receives an import map which contains custom field labels and a custom fields object
@@ -87,7 +87,7 @@ export function generateImportHandlers(importMap: ImportMap) {
timeDanger: null,
endAction: null,
timerType: null,
entryId: null,
id: null,
custom: {},
};
@@ -155,8 +155,8 @@ export function generateImportHandlers(importMap: ImportMap) {
indexMap.timeDanger = col;
sheetMetadata.timeDanger = { row, col };
},
[importMap.entryId]: (row: number, col: number) => {
indexMap.entryId = col;
[importMap.id]: (row: number, col: number) => {
indexMap.id = col;
sheetMetadata['id'] = { row, col }; // important this will be used in a normal context where the id is not called entryId
},
custom: (row: number, col: number, columnText: string, ontimeKey: string) => {
@@ -167,3 +167,171 @@ export function generateImportHandlers(importMap: ImportMap) {
return { handlers, indexMap, sheetMetadata };
}
const emptyCellValue = '';
/**
* Converts a given rundown to 2D array tabular format
* All fields are converted to their string representation as specified by excel
*/
export function rundownToTabular(rundown: Rundown, customFields: CustomFields): string[][] {
const flatRundown: string[][] = [];
// we reuse the keys from the import map so import <> export works by default
const keys = Object.keys(matchedWithDefaultImportMap) as (keyof typeof matchedWithDefaultImportMap)[];
const customFieldKeys = Object.keys(customFields) as CustomFieldKey[];
// create header row
const headerRow: string[] = [];
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
headerRow.push(matchedWithDefaultImportMap[key]);
}
for (let i = 0; i < customFieldKeys.length; i++) {
const key = customFieldKeys[i];
headerRow.push(customFields[key].label);
}
flatRundown.push(headerRow);
// create table body
let previousEntry: OntimeEntry | undefined;
for (let i = 0; i < rundown.flatOrder.length; i++) {
const entryId = rundown.flatOrder[i];
const entry = rundown.entries[entryId];
if (!entry) continue;
// we opt for not exporting delays as they are only relevant for runtime
if (entry.type === 'delay') continue;
// we need a group-end if:
// - the previous entry was a group and now we have another group (both parents are undefined)
// - the previous entry had a parent and the new entry is a group (parent undefined)
// - the previous entry had a parent and the new one doesnt (parent null)
if (
previousEntry &&
((previousEntry.type === 'group' && entry.type === 'group') ||
// @ts-expect-error -- we safely check if the property is nullish
(previousEntry?.parent !== null && entry?.parent == null))
) {
addGroupEnd();
}
previousEntry = entry;
const entryRow: string[] = [];
// each entry is a row in the table
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
// handle edge cases for group
if (entry.type === 'group') {
if (key === 'duration') {
const groupDuration = entry.targetDuration === null ? '' : parseField('duration', entry.targetDuration);
entryRow.push(groupDuration);
continue;
} else if (key === 'timerType') {
entryRow.push('group');
continue;
}
}
// handle edge cases for milestones
if (entry.type === 'milestone') {
if (key === 'timerType') {
entryRow.push('milestone');
continue;
}
}
// handle edge cases for events
if (entry.type === 'event') {
if (key === 'endAction' && entry.endAction === 'none') {
entryRow.push(emptyCellValue);
continue;
}
}
if (key in entry) {
entryRow.push(parseField(key, entry[key as keyof typeof entry]));
} else {
// we push an empty cell to keep the column positioning
entryRow.push(emptyCellValue);
}
}
// add custom fields
if ('custom' in entry) {
for (let i = 0; i < customFieldKeys.length; i++) {
const customFieldKey = customFieldKeys[i] as CustomFieldKey;
const value = entry.custom?.[customFieldKey] ?? emptyCellValue;
entryRow.push(String(value));
}
}
flatRundown.push(entryRow);
}
// close dangling groups
// - last element as a group: previous entry.parent is undefined
// - last element was in a group: previous entry.parent is string
if (previousEntry && (previousEntry.type === 'group' || previousEntry.parent !== null)) {
addGroupEnd();
}
/**
* Adds a new row with group-end timerType
* Ensures that all columns are filled with the correct type
*/
function addGroupEnd() {
const emptyRow = Array(keys.length).fill(emptyCellValue);
const timerTypeIndex = keys.indexOf('timerType');
emptyRow[timerTypeIndex] = 'group-end';
flatRundown.push(emptyRow);
}
return flatRundown;
}
/**
* Map between the ontime key and the excel column name
* This needs to match match the default import map
* TODO: could we organise these objects to have a single source of truth?
*/
const matchedWithDefaultImportMap = {
id: 'ID',
flag: 'Flag',
cue: 'Cue',
title: 'Title',
colour: 'Colour',
timeStart: 'Time Start',
timeEnd: 'Time End',
duration: 'Duration',
linkStart: 'Link Start',
countToEnd: 'Count to end',
note: 'Note',
timerType: 'Timer Type',
endAction: 'End Action',
timeWarning: 'Warning Time',
timeDanger: 'Danger Time',
skip: 'Skip',
};
/**
* Parses a fields value into a string suitable for excel
*/
function parseField(field: string, data: unknown) {
if (data == null) return emptyCellValue;
if (
field === 'timeStart' ||
field === 'timeEnd' ||
field === 'duration' ||
field === 'timeWarning' ||
field === 'timeDanger'
) {
return millisToString(data as MaybeNumber, { fallback: emptyCellValue });
}
if (typeof data === 'boolean') {
return data ? 'TRUE' : emptyCellValue;
}
return typeof data == 'string' ? data : String(data);
}
@@ -1,6 +1,7 @@
import { isImportMap } from 'ontime-utils';
import { body } from 'express-validator';
import { body, param } from 'express-validator';
import {
requestValidationFunction,
requestValidationFunctionWithFile,
@@ -17,3 +18,5 @@ export const validateImportMapOptions = [
requestValidationFunction,
];
export const validateRundownExport = [param('rundownId').isString().trim().notEmpty(), requestValidationFunction];