109 lines
4.7 KiB
TypeScript
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;
|
|
}
|