import type { DatasetSheet } from '../../domain/dataset'; import type { FindingRecord } from '../../domain/finding'; import { toolIdForRecord } from '../../importers/status-normalization'; import { clean, plainTextValue } from '../../shared/text'; export function resultReferenceFields(record: FindingRecord) { return record.fields.filter( field => field.value && /^(?:result|results|result\s*(?:file|datei)|resultdatei|ergebnis|ergebnisse|ergebnisdatei)$/i.test(clean(field.label)) ); } export function resultReferenceValues(record: FindingRecord): string[] { return resultReferenceFields(record) .flatMap(field => { const raw = String(field.value ?? ''); const hrefs = [...raw.matchAll(/href\s*=\s*['"]([^'"]+)['"]/gi)].map(match => { try { return decodeURIComponent(match[1]); } catch { return match[1]; } }); return [plainTextValue(raw), raw, ...hrefs]; }) .filter(Boolean); } /** * Sieht ein Referenzwert nach einer Datei aus? * * `resultReferenceValues` liefert bewusst alles, was als Verweis in Frage * kommt, damit die Zuordnung tolerant bleibt. Fuer die Anzeige taugt das nicht: * Werkzeuge wie Maester schreiben ihr vollstaendiges Ergebnis samt Tabelle in * dasselbe Feld, das bei Purple Knight einen Dateinamen enthaelt. */ export function isFileReference(value: string): boolean { const candidate = clean(value); if (!candidate || candidate.length > 120) return false; if (candidate.includes('|')) return false; if (/\.(?:csv|tsv|xlsx?|xlsb|ods|json|html?)\b/i.test(candidate)) return true; // Ohne erkannte Dateiendung nur akzeptieren, wenn ein Pfadtrenner vorliegt: // ein gewoehnlicher Satz (z. B. Maesters Ergebnistext) enthaelt weder eine // Endung noch einen Trenner und darf nicht als Dateiverweis erscheinen. if (!/[\\/]/.test(candidate)) return false; return /^[\w .\-/\\]+$/.test(candidate); } export function linkedFileKey(value: unknown): string { return plainTextValue(value) .toLocaleLowerCase('de-DE') .replace(/^.*[\\/]/, '') .replace(/\.(?:csv|tsv|xlsx?|xlsb|ods|json|html?)\b.*$/i, '') .replace(/[^a-z0-9]+/g, ''); } function linkedFileWords(value: unknown): string[] { return plainTextValue(value) .toLocaleLowerCase('de-DE') .split(/[^a-z0-9]+/) .filter(word => word.length >= 4 && !['results', 'result', 'report', 'security', 'assessment'].includes(word)); } export function linkedSheetScore(sheet: DatasetSheet, references: string[], identityTokens: string[]): number { const names = [...new Set([sheet.sourceFile, sheet.sourceSheet, sheet.name].filter(Boolean) as string[])]; let score = 0; references.forEach(reference => names.forEach(name => { const referenceKey = linkedFileKey(reference); const nameKey = linkedFileKey(name); if (!referenceKey || !nameKey) return; if (referenceKey === nameKey) score = Math.max(score, 120); else if (Math.min(referenceKey.length, nameKey.length) >= 16 && (referenceKey.includes(nameKey) || nameKey.includes(referenceKey))) { score = Math.max(score, 100); } }) ); const normalizedNames = names.map(linkedFileKey).join(' '); if (identityTokens.some(token => normalizedNames.includes(linkedFileKey(token)))) score = Math.max(score, 80); const referenceWords = new Set(references.flatMap(linkedFileWords)); const sharedWords = names.flatMap(linkedFileWords).filter(word => referenceWords.has(word)); if (sharedWords.length >= 3) score = Math.max(score, 65 + Math.min(10, sharedWords.length)); return score; } interface SourceTableReference { tableIndex: number; label?: string; } /** Provenienzspalte der Projektansicht (siehe `canonicalModelsToDataset`). */ const RECORD_SOURCE_FILE_LABEL = /^(?:quelle:\s*datei|source\s*file|source:\s*file)$/i; /** * Die Datei, aus der ein Datensatz stammt. * * `record.sourceFile` ist der Dateiname des Blattes, auf dem der Datensatz * gerade gelesen wird. In der Projektansicht (" · Alle Datensätze") * ist das nicht die Quelldatei, sondern der Projektname; die tatsaechliche * Datei steht dort als Provenienzfeld "Quelle: Datei". Ohne diese * Unterscheidung fand ein `source-table:`-Verweis genau in der Ansicht kein * Blatt, in der Findings ueblicherweise gelesen werden. */ function recordSourceFile(record: FindingRecord): string { const provenance = record.fields.find(field => RECORD_SOURCE_FILE_LABEL.test(clean(field.label)))?.value; return clean(provenance) || clean(record.sourceFile); } function sourceTableReferences(references: string[]): SourceTableReference[] { const result: SourceTableReference[] = []; for (const reference of references) { if (typeof reference !== 'string') continue; const pattern = /source-table:(\d+)(?:\s+"([^"]+)")?/gi; let match: RegExpExecArray | null; while ((match = pattern.exec(reference))) { result.push({ tableIndex: Number(match[1]), label: clean(match[2]) }); } } return result.filter(item => Number.isInteger(item.tableIndex) && item.tableIndex >= 0); } function hasConflictingTool(sheet: DatasetSheet, recordToolId: string | undefined): boolean { const sheetToolId = clean(sheet.toolId).toLocaleLowerCase('en-US'); const resolvedRecordToolId = clean(recordToolId).toLocaleLowerCase('en-US'); return Boolean( resolvedRecordToolId && resolvedRecordToolId !== 'generic' && sheetToolId && sheetToolId !== 'generic' && sheetToolId !== resolvedRecordToolId ); } const linkedSheetsCache = new WeakMap>(); export function linkResultSheets(record: FindingRecord, sheets: DatasetSheet[], activeSheetId: string): DatasetSheet[] { let recordCache = linkedSheetsCache.get(record); if (!recordCache) { recordCache = new Map(); linkedSheetsCache.set(record, recordCache); } const cached = recordCache.get(activeSheetId); if (cached) return cached; if (!resultReferenceFields(record).length || sheets.length < 2) { recordCache.set(activeSheetId, []); return []; } const references = resultReferenceValues(record); const activeSheetToolId = sheets.find(sheet => sheet.id === activeSheetId)?.toolId; const recordToolId = toolIdForRecord(record, activeSheetToolId); const tableReferences = sourceTableReferences(references); const directTableLabels = new Map(tableReferences.map(reference => [reference.tableIndex, reference.label])); // Ein Quelltabellenverweis ist abschliessend: er benennt genau die Tabellen // des eigenen Regelblocks. Faellt er auf die Namensaehnlichkeit unten // zurueck, zieht er jede gleichnamige Tabelle des Berichts herein - CIS-CAT // gibt dieselbe Belegtabelle je Regel erneut aus, aus einer Tabelle wurden // so Dutzende mit tausenden Zeilen. Loest der Verweis nichts auf, bleibt das // Ergebnis deshalb leer und der Reader weist auf die fehlende Datei hin. if (directTableLabels.size > 0) { const sourceFile = recordSourceFile(record); const directMatches = sheets.filter( sheet => !hasConflictingTool(sheet, recordToolId) && (!sourceFile || sheet.sourceFile === sourceFile) && sheet.sourceTableIndex !== undefined && directTableLabels.has(sheet.sourceTableIndex) ).map(sheet => { const label = directTableLabels.get(sheet.sourceTableIndex!); return label ? { ...sheet, name: label } : sheet; }); recordCache.set(activeSheetId, directMatches); return directMatches; } const tokenSource = [...references, ...record.fields.map(field => plainTextValue(field.value))].join(' '); const identityTokens = [...tokenSource.matchAll(/(?:^|[^a-z0-9])((?:SI|ID|IND)[-_ ]?\d{3,})(?=$|[^a-z0-9])/gi)].map( match => match[1] ); const fileReferences = references.filter(isFileReference); const linkReferences = fileReferences.length > 0 ? fileReferences : identityTokens; if (!linkReferences.length) { recordCache.set(activeSheetId, []); return []; } // Ab hier gibt es keinen Quelltabellenverweis mehr: die Zuordnung geschieht // ueber den Dateinamen, den Werkzeuge wie Purple Knight im Ergebnisfeld // nennen. const candidates = sheets .filter(sheet => !hasConflictingTool(sheet, recordToolId)) .map(sheet => ({ sheet, score: linkedSheetScore(sheet, linkReferences, identityTokens) })) .filter(item => !item.sheet.isProjectSheet && item.sheet.id !== activeSheetId && item.score >= 65) .sort((left, right) => right.score - left.score); const result = !candidates.length ? [] : (candidates[0].score >= 100 ? candidates.filter(item => item.score === candidates[0].score) : [candidates[0]]).map(item => item.sheet); recordCache.set(activeSheetId, result); return result; }