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
+16
View File
@@ -3,6 +3,7 @@ import { CustomFields, Rundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants';
import { downloadBlob } from './utils';
const excelPath = `${apiEntryUrl}/excel`;
@@ -39,3 +40,18 @@ export async function importRundownPreview(options: ImportMap): Promise<PreviewS
});
return response.data;
}
/**
* Downloads a xlsx representation of the rundown from the server
*/
export async function downloadAsExcel(rundownId: string, fileName?: string) {
try {
const response = await axios.get(`${excelPath}/${rundownId}/export`, {
responseType: 'blob',
});
downloadBlob(response.data, `${fileName ?? 'Ontime_rundown'}.xlsx`);
} catch (error) {
console.error('Error downloading file:', error);
}
}
@@ -1,10 +1,13 @@
import { useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { IoAdd, IoDocumentOutline, IoDownloadOutline, IoEllipsisHorizontal, IoTrash } from 'react-icons/io5';
import { useDisclosure } from '@mantine/hooks';
import { downloadAsExcel } from '../../../../common/api/excel';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Dialog from '../../../../common/components/dialog/Dialog';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Tag from '../../../../common/components/tag/Tag';
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
import { cx } from '../../../../common/utils/styleUtils';
@@ -55,6 +58,10 @@ export default function ManageRundowns() {
}
};
const handleDownloadXlsx = async (rundownId: string, title: string) => {
await downloadAsExcel(rundownId, title);
};
return (
<>
<Panel.Section>
@@ -93,19 +100,35 @@ export default function ManageRundowns() {
<td>
{title} {isLoaded && <Tag>Loaded</Tag>}
</td>
<Panel.InlineElements as='td'>
<Button size='small' onClick={() => openLoad(id)} disabled={isLoaded}>
Load
</Button>
<Button
size='small'
variant='subtle-destructive'
onClick={() => openDelete(id)}
disabled={isLoaded}
<td>
<DropdownMenu
render={<IconButton variant='ghosted-white' />}
items={[
{
type: 'item',
icon: IoDownloadOutline,
label: 'Load',
onClick: () => openLoad(id),
disabled: isLoaded,
},
{
type: 'item',
icon: IoDocumentOutline,
label: 'Download .xlsx',
onClick: () => handleDownloadXlsx(id, title),
},
{
type: 'item',
icon: IoTrash,
label: 'Delete',
onClick: () => openDelete(id),
disabled: isLoaded,
},
]}
>
Delete
</Button>
</Panel.InlineElements>
<IoEllipsisHorizontal />
</DropdownMenu>
</td>
</tr>
);
})}
@@ -59,7 +59,7 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
timeWarning: namedImportMap['Time warning'],
timeDanger: namedImportMap['Time danger'],
custom,
entryId: namedImportMap.ID,
id: namedImportMap.ID,
};
}
@@ -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];
@@ -8,6 +8,7 @@ import {
AuthenticationStatus,
CustomFields,
DatabaseModel,
EntryId,
isOntimeEvent,
isOntimeMilestone,
LogOrigin,
@@ -360,7 +361,7 @@ export async function upload(sheetId: string, options: ImportMap) {
const rundown = getCurrentRundown();
const sheetOrder: string[] = [];
let prevGroup: string | null = null;
let prevGroup: EntryId | null = null;
for (const id of rundown.flatOrder) {
const entry = rundown.entries[id];
@@ -418,12 +419,12 @@ export async function upload(sheetId: string, options: ImportMap) {
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]);
? ({ 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}`)
throw new Error(`Sheet write failed to correctly parse rundown: ${e}`);
}
const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({
@@ -1,4 +1,3 @@
import type { AutomationSettings } from './core/Automation.type.js';
import type { CustomFields } from './core/CustomFields.type.js';
import type { ProjectData } from './core/ProjectData.type.js';
@@ -21,7 +21,7 @@ describe('isImportMap()', () => {
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {},
entryId: 'id',
id: 'id',
};
expect(isImportMap(importMap)).toBe(true);
@@ -72,7 +72,7 @@ describe('isImportMap()', () => {
userDefined: 'userDefined',
anotherOne: 'anotherOne',
},
entryId: 'id',
id: 'id',
};
expect(isImportMap(importMap)).toBe(true);
@@ -21,7 +21,7 @@ export const defaultImportMap = {
timeWarning: 'warning time',
timeDanger: 'danger time',
custom: {},
entryId: 'id',
id: 'id',
};
/**