57 lines
3.6 KiB
TypeScript
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;
|
|
}
|