From c3ef1a6aed5b4eee2d70dd3b28534c0b2c1a7628 Mon Sep 17 00:00:00 2001 From: muena Date: Fri, 25 Sep 2026 10:49:21 +0200 Subject: [PATCH] Recheck project filenames and Word commenters --- README.md | 2 +- src/lib/project-review.ts | 14 ++++++++++++++ src/lib/project-revision.ts | 1 + src/lib/velum-project.ts | 2 ++ src/pseudonymize-project-files.tsx | 26 +++++++++++++++++++------- src/velum.ts | 3 ++- tests/project-review.test.ts | 13 ++++++++++++- 7 files changed, 51 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3acbbc1..aee3f29 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Raycast-Extension für PII-sichere Text- und Projekt-Workflows mit Velum: Inhalt ## Projektdateien -`Projektdateien pseudonymisieren` übernimmt zuerst den geöffneten Finder-Ordner; bei Bedarf kann er im Formular erneut übernommen oder manuell gewählt werden. Im Formular werden die zu erkennenden Kategorien ausgewählt (Orte sind wegen häufiger Fehlalarme zunächst abgewählt). Danach analysiert Velum alle geeigneten Text-, DOCX-, XLSX-, PPTX- und PDF-Dateien sowie ihre Namen. Kleine Textdateien und Namen werden in Paketen analysiert, um die Anzahl der API-Aufrufe zu verringern. Die Prüfung zeigt jede eindeutige Zuordnung einmal, nach Kategorie gruppiert, mit der Anzahl ihrer Vorkommen. Eine Zuordnung an- oder abzuwählen gilt für alle betroffenen Dateien; einzelne Vorkommen lassen sich bei Bedarf ansehen. Nach der Auswahl lassen sich erkannte Personen optional zusammenfassen, sodass ihre Erwähnungen denselben Platzhalter erhalten. `Pseudonymisierte Kopien erstellen` schreibt Dateien in den Raycast-Support-Bereich außerhalb des Projektordners. Alternativ ersetzt `Originaldateien ersetzen` die Dateien und gegebenenfalls ihre Namen vor Ort; zuvor legt Velum eine private Sicherung der Originale im Raycast-Support-Bereich an. PDF wird dabei als DOCX ausgegeben. +`Projektdateien pseudonymisieren` übernimmt zuerst den geöffneten Finder-Ordner; bei Bedarf kann er im Formular erneut übernommen oder manuell gewählt werden. Im Formular werden die zu erkennenden Kategorien ausgewählt (Orte sind wegen häufiger Fehlalarme zunächst abgewählt). Danach analysiert Velum alle geeigneten Text-, DOCX-, XLSX-, PPTX- und PDF-Dateien sowie ihre Namen. Unterstriche zwischen Namensbestandteilen werden bei der Erkennung als Worttrenner behandelt; bekannte Platzhalter und wörtliche Codebegriffe bleiben erhalten. Auch Namen unveränderter Dateien werden in einem Folgelauf erneut geprüft. Ältere Word-Projektstände werden einmalig auf Kommentatoren geprüft, sofern `PERSON` ausgewählt ist. Kleine Textdateien und Namen werden in Paketen analysiert, um die Anzahl der API-Aufrufe zu verringern. Die Prüfung zeigt jede eindeutige Zuordnung einmal, nach Kategorie gruppiert, mit der Anzahl ihrer Vorkommen. Eine Zuordnung an- oder abzuwählen gilt für alle betroffenen Dateien; einzelne Vorkommen lassen sich bei Bedarf ansehen. Nach der Auswahl lassen sich erkannte Personen optional zusammenfassen, sodass ihre Erwähnungen denselben Platzhalter erhalten. `Pseudonymisierte Kopien erstellen` schreibt Dateien in den Raycast-Support-Bereich außerhalb des Projektordners. Alternativ ersetzt `Originaldateien ersetzen` die Dateien und gegebenenfalls ihre Namen vor Ort; zuvor legt Velum eine private Sicherung der Originale im Raycast-Support-Bereich an. PDF wird dabei als DOCX ausgegeben. Projektbegriffe werden zeilenweise als `BEGRIFF: KundenSuite` oder `PERSON: Max Mustermann` eingegeben, nur für dieses Projekt gespeichert und mit `force_masks` pro API-Aufruf übertragen. Die globale Velum-Regelliste bleibt unverändert. Beim Erstellen eines Projektstands werden abgewählte Treffer als typgebundene Projekt-Whitelist außerhalb des Quellordners gespeichert und bei späteren Läufen nicht erneut angeboten. Die Whitelist lässt sich im Startformular bearbeiten. Eine neue Projektbearbeitung übernimmt die bestätigten Zuordnungen des letzten Projektstands, sodass Platzhalter konsistent bleiben. Nach einem Lauf mit Kopien bleibt der Klartext-Quellordner erhalten und kann nach Dateiänderungen erneut bearbeitet werden. Nach dem Ersetzen vor Ort werden unveränderte bereits maskierte Dateien in den nächsten Stand übernommen; neue oder geänderte Dateien werden analysiert. Ein neuer Projektstand enthält auch die übernommenen Dateien für die Rückübersetzung. diff --git a/src/lib/project-review.ts b/src/lib/project-review.ts index cd5eaee..8e55730 100644 --- a/src/lib/project-review.ts +++ b/src/lib/project-review.ts @@ -9,6 +9,20 @@ export type BatchItem = { key: string; text: string }; export type BatchSegment = BatchItem & { start: number; end: number }; export type TextBatch = { text: string; segments: BatchSegment[] }; +/** Filename separators look like spaces to NER, except inside known literal terms. */ +export function filenameScanText(stem: string, knownTerms: Iterable): string { + const protectedOffsets = new Set(); + for (const term of knownTerms) { + if (!term.includes("_")) continue; + const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp(`(? protectedOffsets.has(offset) ? "_" : " "); +} + /** Keep file boundaries while reducing many small text/path API calls to a few batches. */ export function makeTextBatches(items: BatchItem[], limit = MAX_BATCH_CODEPOINTS): TextBatch[] { const batches: TextBatch[] = []; diff --git a/src/lib/project-revision.ts b/src/lib/project-revision.ts index 72be595..d1251dd 100644 --- a/src/lib/project-revision.ts +++ b/src/lib/project-revision.ts @@ -112,6 +112,7 @@ export async function reviseProjectRun(args: { const { directory, run } = created; run.origin = previous.origin; run.revisedFrom = previous.id; + run.commentAuthorCoverage = previous.commentAuthorCoverage; run.mapping = plan.mapping; run.matchModes = Object.fromEntries(Object.entries(previous.matchModes ?? {}) .filter(([key]) => key in plan.mapping)); diff --git a/src/lib/velum-project.ts b/src/lib/velum-project.ts index 7916b85..307d908 100644 --- a/src/lib/velum-project.ts +++ b/src/lib/velum-project.ts @@ -28,6 +28,8 @@ export type ProjectRun = { sourceInPlace?: boolean; /** Previous immutable snapshot when a project run was revised after creation. */ revisedFrom?: string; + /** Word comment authors were reviewed with the explicit author recognizer. */ + commentAuthorCoverage?: boolean; }; const DOCUMENTS = new Set([".docx", ".xlsx", ".pptx", ".pdf"]); diff --git a/src/pseudonymize-project-files.tsx b/src/pseudonymize-project-files.tsx index 365fc9a..b23a9e6 100644 --- a/src/pseudonymize-project-files.tsx +++ b/src/pseudonymize-project-files.tsx @@ -14,7 +14,7 @@ import { } from "./lib/velum-project"; import type { ProjectFile, ProjectRule, ProjectWhitelistEntry, RunFile } from "./lib/velum-project"; import { - canonicalPerson, groupReviewHits, makeTextBatches, mappingForSelection, mergePeople, spansBySegment, + canonicalPerson, filenameScanText, groupReviewHits, makeTextBatches, mappingForSelection, mergePeople, spansBySegment, withoutWhitelistedHits, withoutWhitelistedRules, } from "./lib/project-review"; @@ -117,7 +117,9 @@ async function prepare(root: string, files: ProjectFile[], rules: ProjectRule[], for (const file of files) { const data = await fs.readFile(file.absPath); const hash = digest(data); - if (previousDirectory && previousOutputs.has(file.relPath) + const recheckCommentAuthors = entityTypes.includes("PERSON") && file.kind === "document" + && extname(file.relPath).toLowerCase() === ".docx" && !latest?.commentAuthorCoverage; + if (!recheckCommentAuthors && previousDirectory && previousOutputs.has(file.relPath) && await matchesPreviousOutput(previousDirectory, file.relPath, hash)) { retained.push({ file, hash }); continue; @@ -151,8 +153,14 @@ async function prepare(root: string, files: ProjectFile[], rules: ProjectRule[], mapping = result.mapping; addHits(draft, documentHits(draft.file, result.hits)); } - const paths = pathSegments(drafts.map((draft) => draft.file)); - const pathBatches = makeTextBatches(paths.map((path) => ({ key: path.key, text: path.value }))); + // Recheck names even when an in-place file's contents are unchanged. An + // earlier run may have masked the content but left the filename clear. + const paths = pathSegments(files); + const knownTerms = [...Object.keys(mapping), ...Object.values(mapping).map((entry) => entry.original), + ...withoutWhitelistedRules(rules, whitelist).map((rule) => rule.value)]; + const pathBatches = makeTextBatches(paths.map((path) => ({ + key: path.key, text: filenameScanText(path.value, knownTerms), + }))); const byPath = new Map(paths.map((path) => [path.key, path])); for (const [index, batch] of pathBatches.entries()) { onProgress(`Dateinamen analysieren: Paket ${index + 1}/${pathBatches.length}`); @@ -161,7 +169,7 @@ async function prepare(root: string, files: ProjectFile[], rules: ProjectRule[], const spans = spansBySegment(batch, result.spans); for (const segment of batch.segments) { const path = byPath.get(segment.key)!; - path.hits = withoutWhitelistedHits(toUtf16Offsets(path.value, spans.get(path.key) ?? []).map((span, hitIndex) => ({ + path.hits = withoutWhitelistedHits(toUtf16Offsets(segment.text, spans.get(path.key) ?? []).map((span, hitIndex) => ({ ...span, id: `${path.key}:path:${hitIndex}`, file: path.key, source: "Datei- oder Ordnername", kind: "path" as const, context: path.value, @@ -263,6 +271,7 @@ function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: Proje selectedPeople.has(source) && availableTargets.has(canonicalPerson(target, personMerges)))); run.mapping = mappingForSelection(props.preview.mapping, props.preview.previousMapping, acceptedHits, activeMerges); + run.commentAuthorCoverage = props.preview.entityTypes.includes("PERSON"); run.matchModes = props.preview.matchModes; run.sourceInPlace = props.preview.sourceInPlace || inPlace; const usedPaths = new Set(); @@ -270,13 +279,16 @@ function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: Proje for (const retained of props.preview.retained) { const data = await fs.readFile(retained.file.absPath); if (digest(data) !== retained.hash) throw new Error(`Datei wurde seit der Vorschau geändert: ${retained.file.relPath}`); - const relPath = safeRelativePath(retained.file.relPath); + const relPath = outputPath(retained.file.relPath, props.preview.paths, accepted, + retained.file.kind === "document", activeMerges); if (usedPaths.has(relPath)) throw new Error(`Zwei Dateien hätten denselben Ausgabenamen: ${relPath}`); usedPaths.add(relPath); const target = resolveWithin(join(directory, "files"), relPath); await fs.mkdir(dirname(target), { recursive: true, mode: 0o700 }); await fs.writeFile(target, data, { mode: 0o600 }); - run.files.push({ source: relPath, output: relPath, kind: retained.file.kind }); + const file: RunFile = { source: retained.file.relPath, output: relPath, kind: retained.file.kind }; + run.files.push(file); + if (file.source !== file.output) replacements.push(file); } for (const draft of props.preview.drafts) { const data = await fs.readFile(draft.file.absPath); diff --git a/src/velum.ts b/src/velum.ts index 253277f..ac37351 100644 --- a/src/velum.ts +++ b/src/velum.ts @@ -142,7 +142,8 @@ export async function requireProjectCapabilities(): Promise { const response = await fetchWithAuth(apiUrl("/api/project-capabilities")); if (!response.ok) throw new Error("Die Velum-API muss für die Projektfunktionen aktualisiert werden."); const capabilities = await parseJsonResponse>(response); - if (!capabilities.request_force_masks || !capabilities.document_hit_review || !capabilities.document_restoration) { + if (!capabilities.request_force_masks || !capabilities.document_hit_review + || !capabilities.document_restoration || !capabilities.comment_author_review) { throw new Error("Die Velum-API unterstützt die Projektfunktionen noch nicht vollständig."); } } diff --git a/tests/project-review.test.ts b/tests/project-review.test.ts index 47ad20a..63eeedd 100644 --- a/tests/project-review.test.ts +++ b/tests/project-review.test.ts @@ -1,9 +1,20 @@ import { expect, it } from "bun:test"; import { - canonicalPerson, groupReviewHits, makeTextBatches, mappingForSelection, mergePeople, + canonicalPerson, filenameScanText, groupReviewHits, makeTextBatches, mappingForSelection, mergePeople, spansBySegment, withoutWhitelistedHits, withoutWhitelistedRules, } from "../src/lib/project-review"; +it("liest Unterstriche in Namen als Leerzeichen und bewahrt bekannte Codebegriffe", () => { + expect(filenameScanText("Max_Mustermann_Abgabe", [])) + .toBe("Max Mustermann Abgabe"); + expect(filenameScanText("Max_Mustermann_KundenSuite_X", ["KundenSuite_X"])) + .toBe("Max Mustermann KundenSuite_X"); + expect(filenameScanText("😀_Max_Mustermann", [])) + .toBe("😀 Max Mustermann"); + expect(filenameScanText("PERSON_01_Max_Mustermann", ["PERSON_01"])) + .toBe("PERSON_01 Max Mustermann"); +}); + it("ordnet gebündelte API-Treffer nach Unicode-Positionen den richtigen Dateien zu", () => { const [batch] = makeTextBatches([ { key: "eins.txt", text: "😀 Max" },