Assessor_2.0/src/exports/unified-result-excel.ts

57 lines
3.6 KiB
TypeScript

import { utils, write } from 'xlsx';
import type { UnifiedResultProjection, UnifiedResultQuery } from '../domain/unified-result';
import { buildUnifiedResultJsonl } from './unified-result-jsonl';
export const RESULT_DB_EXCEL_MEDIA_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
const CELL_LIMIT = 32767;
const SHEET_ROWS = 1048576;
export function unifiedResultExcelFileName(generatedAt: string): string {
return `assessor-result-db-filtered-${generatedAt.slice(0, 10)}.xlsx`;
}
/** Consume the exact JSONL disclosure boundary; never export raw source projections. */
export function buildUnifiedResultExcel(query: UnifiedResultQuery, results: UnifiedResultProjection[], generatedAt: string): ArrayBuffer {
const [manifest, ...entries] = buildUnifiedResultJsonl(query, results, generatedAt).trimEnd().split('\n')
.map(line => JSON.parse(line) as Record<string, unknown>);
const records = entries.map(entry => entry.result as Record<string, unknown>);
const preferred = ['title', 'sourceRuleId', 'sourceTool', 'severityNormalized', 'outcomeNormalized', 'resultType', 'isIoe', 'isDeviation', 'description', 'recommendation'];
const fields = [...new Set(records.flatMap(record => Object.keys(record)))];
fields.sort((a, b) => {
const rank = (field: string) => preferred.includes(field) ? preferred.indexOf(field) : preferred.length;
return rank(a) - rank(b) || a.localeCompare(b);
});
const overflow: (string | number | boolean)[][] = [['Position', 'Field', 'Part', 'Text']];
const cell = (value: unknown, position: number, field: string): string | number | boolean => {
if (value === undefined || value === null) return '';
if (typeof value === 'number' || typeof value === 'boolean') return value;
const text = typeof value === 'string' ? value : JSON.stringify(value);
if (text.length <= CELL_LIMIT) return text;
let offset = 0, part = 1;
while (offset < text.length) {
let end = Math.min(offset + CELL_LIMIT, text.length);
// Do not split a UTF-16 surrogate pair across Excel cells.
if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1])) end--;
overflow.push([position, field, part++, text.slice(offset, end)]);
offset = end;
}
return `Complete value in Long text: Position ${position}, Field ${field}`;
};
const rows = [['Position', ...fields], ...records.map((record, index) =>
[index + 1, ...fields.map(field => cell(record[field], index + 1, field))])];
const workbook = utils.book_new();
const append = (name: string, data: (string | number | boolean)[][], widths: number[]) => {
if (data.length > SHEET_ROWS) throw new Error('The Excel worksheet row limit was exceeded. Narrow the filters or use JSONL.');
const sheet = utils.aoa_to_sheet(data);
sheet['!cols'] = widths.map(wch => ({ wch }));
sheet['!autofilter'] = { ref: sheet['!ref']! };
utils.book_append_sheet(workbook, sheet, name);
};
append('Results', rows, [12, ...fields.map(field => ['title', 'description', 'recommendation'].includes(field) ? 65 : 25)]);
append('Export info', [['Property', 'Value'], ...Object.entries(manifest).map(([key, value]) =>
[key, cell(value, 0, key)]), ['Excel notes', 'Nested values are JSON text. Long values are complete in Long text, ordered by Position, Field and Part. Position 0 refers to Export info.']], [28, 100]);
if (overflow.length > 1) append('Long text', overflow, [12, 30, 10, 100]);
// aoa_to_sheet creates literal string cells, including values beginning with =, +, - or @.
return write(workbook, { type: 'array', bookType: 'xlsx', compression: true }) as ArrayBuffer;
}