Assessor_2.0/src/features/linked-results/inline-result-tables.ts

109 lines
4.7 KiB
TypeScript

/**
* Ergebnistabellen, die ein Werkzeug in ein Textfeld schreibt.
*
* Der Ergebnis-Linker sucht eine passende Ergebnisdatei im selben Projekt -
* das Modell von Purple Knight, das seine Detailtabellen als eigene Dateien
* exportiert. Maester legt sie stattdessen als Markdown-Tabelle direkt in
* `ResultDetail`. Fuer den Linker sah dieser Wert wie eine Dateireferenz aus,
* die er nirgends fand; die Schaltflaeche blieb abgeschaltet und die Tabelle
* stand nur als Pipe-Kette im Hinweistext.
*
* Diese Ableitung erzeugt aus einer solchen Inline-Tabelle ein vollwertiges
* Blatt, das dasselbe Ergebnisfenster oeffnet wie eine verknuepfte Datei. Die
* Originalwerte bleiben unveraendert; das Blatt ist eine Projektion, keine
* zweite Datenhaltung.
*/
import type { DatasetSheet } from '../../domain/dataset';
import type { FindingRecord } from '../../domain/finding';
import { makeSheet } from '../../importers/sheet-factory';
import { extractMarkdownTables } from '../../shared/markdown';
import { clean } from '../../shared/text';
/** Felder, in denen Werkzeuge ihre Ergebnisdetails ablegen. */
const RESULT_DETAIL_LABEL =
/^(?:result\s*detail|resultdetail|result\s*details|details?|ergebnisdetails?|befund|evidence|nachweis|result|results|ergebnis|ergebnisse)$/i;
const MAX_INLINE_JSON_LENGTH = 5 * 1024 * 1024;
const MAX_INLINE_JSON_ROWS = 10_000;
const MAX_INLINE_JSON_COLUMNS = 256;
interface InlineTable {
headers: string[];
rows: string[][];
}
/**
* Legacy PurpleKnight HTML imports retained `IndicatorReportObjects` as a
* JSON array whose cells are encoded as `Column: value`. Interpret that inert
* value as a table projection; never execute report code and never mutate the
* preserved source field.
*/
function extractLabelValueJsonTable(value: string): InlineTable | undefined {
const source = value.trim();
if (!source.startsWith('[[') || source.length > MAX_INLINE_JSON_LENGTH) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(source);
} catch {
return undefined;
}
if (!Array.isArray(parsed) || !parsed.length || parsed.length > MAX_INLINE_JSON_ROWS) return undefined;
const rows = parsed.filter((row): row is unknown[] => Array.isArray(row));
if (rows.length !== parsed.length || !rows[0].length || rows[0].length > MAX_INLINE_JSON_COLUMNS) return undefined;
const headers = rows[0].map(cell => {
if (typeof cell !== 'string') return '';
const separator = cell.indexOf(':');
return separator > 0 && separator <= 120 ? cell.slice(0, separator).trim() : '';
});
if (headers.some(header => !header) || new Set(headers).size !== headers.length) return undefined;
const tableRows = rows.map(row => headers.map((header, columnIndex) => {
const cell = row[columnIndex];
const text = cell === null || cell === undefined ? '' : String(cell);
const prefix = `${header}:`;
return text.startsWith(prefix) ? text.slice(prefix.length).trimStart() : text;
}));
return { headers, rows: tableRows };
}
/**
* Die Markdown-Auswertung ist mehrere Regex-Durchlaeufe je Feld (siehe
* `shared/markdown.ts`) und lohnt sich nicht fuer jeden Aufruf neu: bei einem
* grossen Import wird diese Funktion sonst je Datensatz erneut aufgerufen,
* sobald er ins virtualisierte Fenster ruckt. Der Cache haelt sie pro
* Datensatz konstant, genau wie `linkedSheetsCache` in result-linker.ts.
*/
const inlineTableCache = new WeakMap<FindingRecord, DatasetSheet[]>();
export function inlineResultTableSheets(record: FindingRecord): DatasetSheet[] {
const cached = inlineTableCache.get(record);
if (cached) return cached;
const sheets: DatasetSheet[] = [];
for (const field of record.fields) {
if (!field.value || !RESULT_DETAIL_LABEL.test(clean(field.label))) continue;
const markdownTables = extractMarkdownTables(field.value);
const tables: InlineTable[] = markdownTables.length
? markdownTables
: [extractLabelValueJsonTable(field.value)].filter((table): table is InlineTable => Boolean(table));
tables.forEach((table, index) => {
const rows = [table.headers, ...table.rows];
const name = index === 0 ? field.label : `${field.label} (${index + 1})`;
sheets.push(makeSheet(name, rows, rows.map(row => row.join('\t')).join('\n'), record.id, {
sourceFile: record.sourceFile,
sourceSheet: name
}));
});
}
inlineTableCache.set(record, sheets);
return sheets;
}
/**
* Traegt der Datensatz ueberhaupt eine Inline-Ergebnistabelle? Wird gebraucht,
* um den Hinweis "keine Ergebnisdatei gefunden" nur dann zu zeigen, wenn auch
* wirklich nichts vorliegt.
*/
export function hasInlineResultTable(record: FindingRecord): boolean {
return inlineResultTableSheets(record).length > 0;
}