Compare commits

23 Commits

Author SHA1 Message Date
muena
818f9ed814 fix: preserve placeholder case when restoring project text 2026-10-06 13:43:33 +02:00
muena
7db3949d66 fix: restore project placeholders regardless of case 2026-10-06 13:38:21 +02:00
muena
8de3ce9326 fix: restore placeholders within filenames 2026-10-06 13:33:21 +02:00
muena
0ec5b0745e fix: restore current in-place project files 2026-10-06 13:17:18 +02:00
muena
43fbf46014 fix: restore selected project files by default 2026-10-06 13:12:53 +02:00
muena
7bc0142f95 feat: show project and session translation tables 2026-09-28 15:05:01 +02:00
muena
ec1f9a4e55 fix: allow rerunning imported projects 2026-09-28 09:17:53 +02:00
muena
ec1e2d27e0 Preserve rich formatting when restoring selected text 2026-09-27 10:23:37 +02:00
muena
94806aa049 feat: restore current project copies and choose text mapping 2026-09-27 10:12:46 +02:00
muena
ec327a5220 feat: publish Velum copies beside project folder 2026-09-27 08:58:40 +02:00
muena
23af4993db Review Word change authors on existing project runs 2026-09-27 08:47:34 +02:00
muena
e9cda1c02f fix: expose pseudonymized copy location in Raycast 2026-09-27 08:22:06 +02:00
muena
6cb5b0efc5 fix: keep project entity selection and ignore placeholder hits 2026-09-26 15:09:53 +02:00
muena
5877b7cf5f Group repeat pseudonymization runs by project path 2026-09-25 16:17:03 +02:00
muena
c3ef1a6aed Recheck project filenames and Word commenters 2026-09-25 10:49:21 +02:00
muena
d0d6a29ef6 Allow revising saved Velum project mappings 2026-09-25 10:31:45 +02:00
muena
e7e21e9281 feat: remember project exclusions and merge selected people 2026-09-25 08:08:20 +02:00
muena
392db8601a ui: label project pseudonym categories clearly 2026-09-24 20:12:08 +02:00
muena
226d230b6d feat: review project pseudonyms by category 2026-09-24 20:11:10 +02:00
muena
8b254eb87d fix: complete Finder project workflow and add in-place output 2026-09-24 19:55:34 +02:00
muena
8e928bc69f fix: migrate placeholders after encoded path separators 2026-09-24 19:25:06 +02:00
muena
ae62c6f95c feat: add migration preview report 2026-09-24 19:21:07 +02:00
muena
c1229e1d14 Add Velum project workflow and standalone migration tool 2026-09-24 18:14:44 +02:00
30 changed files with 3908 additions and 96 deletions

View File

@@ -1,6 +1,31 @@
# Velum Raycast Extension # Velum Raycast Extension
Raycast-Extension für PII-sichere Text-Workflows mit Velum: Klarnamen werden lokal in Platzhalter überführt, bevor Text an externe KI-Modelle (Raycast AI), Übersetzer oder andere Tools weitergegeben wird. Originalwerte werden anschließend lokal anhand der Velum-Sitzungs-Zuordnung wiederhergestellt. Raycast-Extension für PII-sichere Text- und Projekt-Workflows mit Velum: Inhalte werden über die konfigurierte Velum-API pseudonymisiert, bevor sie an externe KI-Modelle oder andere Tools weitergegeben werden. Die Velum-API sollte für vertrauliche Kundendaten in einer dafür geeigneten Umgebung betrieben werden.
## 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. 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.
`Projektdateien nachbearbeiten` bietet für den aktuellen Projektstand zwei Wege: Zuordnungen nachträglich in Klartext zurückwandeln und als Projekt-Ausnahme speichern sowie Personenplatzhalter nachträglich zusammenführen. Beide Änderungen betreffen Inhalte und Dateinamen; der vorige Stand bleibt als Sicherung erhalten. Bei zuvor direkt ersetzten Projektdateien wird der Ordner nach einer Bestätigung ebenfalls aktualisiert, sofern keine Datei seit dem letzten Stand verändert wurde. Für Projekte mit Kopien entsteht ein neuer Stand außerhalb des Quellordners. Ältere Stände können weiterhin dateiweise als Klartext-Kopien unter `~/Downloads/Velum` exportiert werden. Die Projektfunktion benötigt eine Velum-API mit `/api/project-capabilities`, Trefferprüfung für Dokumente, projektbezogenen `force_masks` und `/api/depseudonymize-document`.
### Einmalige Übernahme aus KI Datenschutz
Velum enthält **keinen** Laufzeit-Zugriff auf die frühere Extension. Das eigenständige Skript `tools/migrate-ki-datenschutz.mjs` wandelt einen bereits anonymisierten KI-Datenschutz-Projektordner samt seiner eingefrorenen Variante-Tabelle in einen Velum-Projektstand um. Es verändert weder den alten Ordner noch die alte Tabelle. Der neue Stand erscheint anschließend unter `Projektdateien nachbearbeiten`; Begriffe nur für dieses Projekt werden als Velum-Projektregeln übernommen. Alle Inhalte bleiben während der Migration lokal und werden nicht an eine API gesendet. Im Terminal protokolliert das Skript nur Zahlen, keine Werte oder Dateinamen.
1. In Raycast `Migrationsziel anzeigen` öffnen und den Velum-Datenordner kopieren.
2. Den alten Projektordner und die zugehörige Datei `projects/<Projekt-ID>.json` aus dem Datenordner von KI Datenschutz angeben. Mit `--preview-file` eine prüfbare Vorschau erstellen:
```bash
node tools/migrate-ki-datenschutz.mjs --project "/Pfad/zum/Projekt" --manifest "/Pfad/zu/projects/abcdef0123456789.json" --target-support "/kopierter/Velum-Datenordner" --preview-file "$HOME/Downloads/Migrationsvorschau.md"
```
Die Datei zeigt Quell- und Zielnamen, alte und neue Platzhalter samt Häufigkeit pro Datei sowie übersprungene Pfade mit Grund. Sie enthält keine Klartextwerte aus der Zuordnungstabelle. Da Dateinamen trotzdem vertraulich sein können, wird sie nur lokal mit eingeschränkten Dateirechten erstellt und eine vorhandene Datei nie überschrieben. Der Vorschaupfad muss außerhalb des Projekts liegen. Ohne `--preview-file` bleibt es bei der Zahlenausgabe im Terminal.
3. Nach Prüfung denselben Befehl **ohne** `--preview-file`, dafür mit `--apply` ausführen. Danach den neuen Stand in Velum öffnen und den Klartext-Export mit einem Testdokument prüfen.
Das Skript übernimmt Text- und Codedateien. Binäre Dateien und bisher nicht anonymisierte Office/PDF-Dateien werden gezählt und ausgelassen; sie müssen im neuen Projektdateien-Befehl geprüft werden. Platzhalter, die in technischen Bezeichnern kleben, behalten ein eigenes Rückübersetzungsverhalten. Wenn die alte Variante-Tabelle mehrere Klartext-Schreibweisen für denselben Platzhalter enthält, ist die exakte frühere Schreibweise nicht mehr rekonstruierbar; das Skript verwendet dieselbe kontextabhängige Kanonisierung wie der alte Klartext-Export. Die alte Extension kann nach erfolgreicher Prüfung entfernt werden.
## Konfiguration ## Konfiguration

View File

@@ -2,7 +2,7 @@
"$schema": "https://www.raycast.com/schemas/extension.json", "$schema": "https://www.raycast.com/schemas/extension.json",
"name": "velum", "name": "velum",
"title": "Velum", "title": "Velum",
"description": "Text per Velum-API pseudonymisieren und wiederherstellen.", "description": "Pseudonymisierung von Texten und Projektdateien mit Velum",
"icon": "extension-icon.png", "icon": "extension-icon.png",
"author": "raphael", "author": "raphael",
"license": "MIT", "license": "MIT",
@@ -29,6 +29,12 @@
"description": "Aus Notizen, Stichpunkten oder einem Transkript ein strukturiertes Briefing per Raycast-KI erstellen — pseudonymisiert.", "description": "Aus Notizen, Stichpunkten oder einem Transkript ein strukturiertes Briefing per Raycast-KI erstellen — pseudonymisiert.",
"mode": "view" "mode": "view"
}, },
{
"name": "summarize-meeting-notes",
"title": "Meeting Notes strukturieren",
"description": "Aus Meeting-Notizen in Prosa eine sauber strukturierte Zusammenfassung (Kontext, Teilnehmer, Themen, Entscheidungen, Action Items) per Raycast-KI erstellen — pseudonymisiert.",
"mode": "view"
},
{ {
"name": "extract-action-items", "name": "extract-action-items",
"title": "Action Items extrahieren", "title": "Action Items extrahieren",
@@ -74,20 +80,47 @@
{ {
"name": "depseudonymize-selected-text", "name": "depseudonymize-selected-text",
"title": "Markierten Text wiederherstellen", "title": "Markierten Text wiederherstellen",
"description": "Aktuell markierten Text mit der aktiven Sitzung wiederherstellen und das Ergebnis kopieren oder einfügen.", "description": "Markierten Text übernehmen, Projekt oder Sitzung wählen und wiederherstellen.",
"mode": "no-view" "mode": "view"
}, },
{ {
"name": "depseudonymize-clipboard", "name": "depseudonymize-clipboard",
"title": "Zwischenablage wiederherstellen", "title": "Zwischenablage wiederherstellen",
"description": "Aktuellen Zwischenablage-Text mit der aktiven Sitzung wiederherstellen und das Ergebnis kopieren oder einfügen.", "description": "Zwischenablage übernehmen, Projekt oder Sitzung wählen und wiederherstellen.",
"mode": "no-view" "mode": "view"
},
{
"name": "show-translation-tables",
"title": "Übersetzungstabellen anzeigen",
"description": "Gespeicherte Zuordnungen je Projekt oder Sitzung durchsuchen.",
"mode": "view"
}, },
{ {
"name": "manage-sessions", "name": "manage-sessions",
"title": "Sitzungen verwalten", "title": "Sitzungen verwalten",
"description": "Velum-Sitzungen mit Zuordnungen anlegen, aktivieren, ansehen und löschen.", "description": "Velum-Sitzungen mit Zuordnungen anlegen, aktivieren, ansehen und löschen.",
"mode": "view" "mode": "view"
},
{
"name": "pseudonymize-project-files",
"title": "Projektdateien pseudonymisieren",
"description": "Finder-Projektordner und Kategorien wählen, Zuordnungen prüfen und Dateien pseudonymisieren.",
"mode": "view",
"icon": "extension-icon.png"
},
{
"name": "restore-project-files",
"title": "Projektdateien wiederherstellen",
"description": "Aktuelle Dateien aus dem Kopienordner als Klartext exportieren, Zuordnungen nachbearbeiten und frühere Stände wiederherstellen.",
"mode": "view",
"icon": "extension-icon.png"
},
{
"name": "migration-location",
"title": "Migrationsziel anzeigen",
"description": "Velum-Datenordner für das einmalige Migrationstool kopieren.",
"mode": "view",
"icon": "extension-icon.png"
} }
], ],
"preferences": [ "preferences": [
@@ -207,7 +240,8 @@
"dev": "ray develop", "dev": "ray develop",
"fix-lint": "ray lint --fix", "fix-lint": "ray lint --fix",
"lint": "ray lint", "lint": "ray lint",
"publish": "ray publish" "publish": "ray publish",
"test": "bun test"
}, },
"dependencies": { "dependencies": {
"@raycast/api": "^1.104.17", "@raycast/api": "^1.104.17",

View File

@@ -13,8 +13,6 @@ import {
Toast, Toast,
useNavigation, useNavigation,
} from "@raycast/api"; } from "@raycast/api";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { marked } from "marked"; import { marked } from "marked";
import { type ReactNode, useEffect, useRef, useState } from "react"; import { type ReactNode, useEffect, useRef, useState } from "react";
import type { Creativity } from "./ai"; import type { Creativity } from "./ai";
@@ -22,6 +20,7 @@ import { getPreferences } from "./preferences";
import type { VelumSession } from "./types"; import type { VelumSession } from "./types";
import { markdownCodeBlock, mappingDetailTable, sessionSubtitle } from "./ui"; import { markdownCodeBlock, mappingDetailTable, sessionSubtitle } from "./ui";
import { localDepseudonymize } from "./velum"; import { localDepseudonymize } from "./velum";
import { copyRichText } from "./rich-clipboard";
export async function maybeCloseRaycast(): Promise<void> { export async function maybeCloseRaycast(): Promise<void> {
if (getPreferences().closeAfterAction) { if (getPreferences().closeAfterAction) {
@@ -29,33 +28,6 @@ export async function maybeCloseRaycast(): Promise<void> {
} }
} }
const execFileAsync = promisify(execFile);
// Raycast 2.0 Beta's Clipboard.copy with { html, text } only writes plain text
// to the system pasteboard. We bypass it via osascript / JXA, which sets both
// the HTML and plain-text pasteboard types directly on NSPasteboard. Content
// is passed via environment variables to avoid shell-quoting hazards.
const RICH_COPY_SCRIPT = [
"ObjC.import('AppKit');",
"const env = $.NSProcessInfo.processInfo.environment;",
"const html = env.objectForKey('VELUM_HTML');",
"const text = env.objectForKey('VELUM_TEXT');",
"const pb = $.NSPasteboard.generalPasteboard;",
"pb.clearContents;",
"pb.setStringForType(html, 'public.html');",
"pb.setStringForType(text, 'public.utf8-plain-text');",
].join("\n");
export async function copyRichText(html: string, text: string): Promise<void> {
await execFileAsync(
"osascript",
["-l", "JavaScript", "-e", RICH_COPY_SCRIPT],
{
env: { ...process.env, VELUM_HTML: html, VELUM_TEXT: text },
},
);
}
export type AiCommandLabels = { export type AiCommandLabels = {
pageHeading: string; pageHeading: string;
runningLabel: string; runningLabel: string;

View File

@@ -1,7 +0,0 @@
import { Clipboard } from "@raycast/api";
import { depseudonymizeQuickText } from "./quick";
export default async function Command() {
const clipboardText = await Clipboard.readText();
await depseudonymizeQuickText(clipboardText ?? "", "Zwischenablage");
}

View File

@@ -0,0 +1,5 @@
import RestoreTextForm from "./depseudonymize-text";
export default function Command() {
return <RestoreTextForm initialSource="clipboard" />;
}

View File

@@ -1,15 +0,0 @@
import { showToast, Toast } from "@raycast/api";
import { depseudonymizeQuickText } from "./quick";
import { getSelectedTextSafely } from "./selection";
export default async function Command() {
const selectedText = await getSelectedTextSafely();
if (!selectedText) {
await showToast({
style: Toast.Style.Failure,
title: "Kein markierter Text gefunden",
});
return;
}
await depseudonymizeQuickText(selectedText, "Selektion");
}

View File

@@ -0,0 +1,5 @@
import RestoreTextForm from "./depseudonymize-text";
export default function Command() {
return <RestoreTextForm initialSource="selection" />;
}

View File

@@ -9,44 +9,111 @@ import {
Toast, Toast,
useNavigation, useNavigation,
} from "@raycast/api"; } from "@raycast/api";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { getSelectedTextSafely } from "./selection";
import {
canonicalProjectPath,
listAllRuns,
listRuns,
restoreTextWithCount,
} from "./lib/velum-project";
import { depseudonymize } from "./velum"; import { depseudonymize } from "./velum";
import { getActiveSessionId, getSession, listSessions } from "./sessions"; import { getActiveSessionId, getSession, listSessions } from "./sessions";
import type { DepseudonymizeResponse, VelumSession } from "./types"; import type { DepseudonymizeResponse, VelumSession } from "./types";
import { markdownCodeBlock, sessionSubtitle } from "./ui"; import { markdownCodeBlock, sessionSubtitle } from "./ui";
import { restoreRichHtml } from "./lib/rich-text";
import { copyRichText } from "./rich-clipboard";
type FormValues = { type FormValues = {
text: string; text: string;
sessionId: string; sessionId: string;
}; };
export default function Command() { const CHOOSE_TARGET = "choose-target";
type RichSource = { text: string; html: string };
export default function Command(
props: { initialSource?: "selection" | "clipboard" } = {},
) {
const [text, setText] = useState(""); const [text, setText] = useState("");
const [richSource, setRichSource] = useState<RichSource>();
const [sessions, setSessions] = useState<VelumSession[]>([]); const [sessions, setSessions] = useState<VelumSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string>(); const [activeSessionId, setActiveSessionId] = useState<string>();
const [projects, setProjects] = useState<
Array<{ path: string; name: string }>
>([]);
const [targetId, setTargetId] = useState(CHOOSE_TARGET);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const { push } = useNavigation(); const { push } = useNavigation();
useEffect(() => { useEffect(() => {
async function load() { async function load() {
const [loadedSessions, activeId] = await Promise.all([ const initialText = (async (): Promise<{
listSessions(), text?: string | null;
getActiveSessionId(), rich?: RichSource;
]); }> => {
if (props.initialSource === "selection") {
const selected = await getSelectedTextSafely();
const clipboard = selected
? await Clipboard.read().catch(() => undefined)
: undefined;
return {
text: selected,
rich:
clipboard?.html && clipboard.text === selected
? { text: selected, html: clipboard.html }
: undefined,
};
}
if (props.initialSource === "clipboard") {
const clipboard = await Clipboard.read();
return {
text: clipboard.text,
rich:
clipboard.html && clipboard.text
? { text: clipboard.text, html: clipboard.html }
: undefined,
};
}
return {};
})();
const [loadedSessions, activeId, savedRuns, selected] = await Promise.all(
[listSessions(), getActiveSessionId(), listAllRuns(), initialText],
);
setSessions(loadedSessions); setSessions(loadedSessions);
setActiveSessionId(activeId); setActiveSessionId(activeId);
const unique = new Map<string, string>();
for (const { run } of savedRuns) {
const path = canonicalProjectPath(run.projectPath);
if (!unique.has(path)) unique.set(path, run.projectName);
}
setProjects([...unique].map(([path, name]) => ({ path, name })));
if (selected.text) {
setText(selected.text);
setRichSource(selected.rich);
} else if (props.initialSource)
await showToast({
style: Toast.Style.Failure,
title:
props.initialSource === "selection"
? "Kein markierter Text gefunden"
: "Zwischenablage enthält keinen Text",
});
setIsLoading(false); setIsLoading(false);
} }
load(); load().catch(async (error: Error) => {
setIsLoading(false);
await showToast({
style: Toast.Style.Failure,
title: "Zuordnungen nicht lesbar",
message: error.message,
});
});
}, []); }, []);
const defaultSessionId = useMemo(
() => activeSessionId || sessions[0]?.id,
[activeSessionId, sessions],
);
async function loadClipboardText() { async function loadClipboardText() {
const clipboardText = await Clipboard.readText(); const clipboard = await Clipboard.read();
const clipboardText = clipboard.text;
if (!clipboardText) { if (!clipboardText) {
await showToast({ await showToast({
style: Toast.Style.Failure, style: Toast.Style.Failure,
@@ -55,23 +122,23 @@ export default function Command() {
return; return;
} }
setText(clipboardText); setText(clipboardText);
setRichSource(
clipboard.html
? { text: clipboardText, html: clipboard.html }
: undefined,
);
} }
async function handleSubmit(values: FormValues) { async function handleSubmit(values: FormValues) {
const input = values.text.trim(); const input = values.text;
if (!input || !values.sessionId) { if (
!input.trim() ||
!values.sessionId ||
values.sessionId === CHOOSE_TARGET
) {
await showToast({ await showToast({
style: Toast.Style.Failure, style: Toast.Style.Failure,
title: "Sitzung wählen und Text eingeben", title: "Projekt oder Sitzung wählen und Text eingeben",
});
return;
}
const session = await getSession(values.sessionId);
if (!session) {
await showToast({
style: Toast.Style.Failure,
title: "Sitzung nicht gefunden",
}); });
return; return;
} }
@@ -81,11 +148,63 @@ export default function Command() {
title: "Stelle wieder her…", title: "Stelle wieder her…",
}); });
try { try {
const result = await depseudonymize(input, session.mapping); let result: DepseudonymizeResponse;
let richHtml: string | undefined;
let targetName: string;
let targetSubtitle: string;
if (values.sessionId.startsWith("project:")) {
const projectPath = values.sessionId.slice("project:".length);
const saved = (await listRuns(projectPath))[0];
if (!saved) throw new Error("Projektstand nicht gefunden.");
const restored = restoreTextWithCount(
input,
saved.run.mapping,
saved.run.matchModes,
);
result = {
original_text: restored.text,
replacements_made: restored.replacements,
};
if (richSource?.text === input) {
richHtml = restoreRichHtml(
richSource.html,
input,
result.original_text,
saved.run.mapping,
saved.run.matchModes,
);
}
targetName = `Projekt ${saved.run.projectName}`;
targetSubtitle = saved.run.projectPath;
} else {
const session = await getSession(values.sessionId);
if (!session) throw new Error("Sitzung nicht gefunden.");
result = await depseudonymize(input, session.mapping);
if (richSource?.text === input) {
richHtml = restoreRichHtml(
richSource.html,
input,
result.original_text,
session.mapping,
undefined,
true,
);
}
targetName = session.name;
targetSubtitle = sessionSubtitle(session);
}
toast.style = Toast.Style.Success; toast.style = Toast.Style.Success;
toast.title = "Wiederhergestellt"; toast.title = "Wiederhergestellt";
toast.message = `${result.replacements_made} Ersetzungen`; toast.message = `${result.replacements_made} Ersetzungen`;
push(<DepseudonymizeResult result={result} session={session} />); push(
<DepseudonymizeResult
result={result}
targetName={targetName}
targetSubtitle={targetSubtitle}
richHtml={richHtml}
showFormatStatus={Boolean(props.initialSource || richSource)}
/>,
);
} catch (error) { } catch (error) {
toast.style = Toast.Style.Failure; toast.style = Toast.Style.Failure;
toast.title = "Wiederherstellung fehlgeschlagen"; toast.title = "Wiederherstellung fehlgeschlagen";
@@ -120,9 +239,23 @@ export default function Command() {
/> />
<Form.Dropdown <Form.Dropdown
id="sessionId" id="sessionId"
title="Sitzung" title="Projekt oder Sitzung"
defaultValue={defaultSessionId} value={targetId}
onChange={setTargetId}
> >
<Form.Dropdown.Item
value={CHOOSE_TARGET}
title="Bitte Projekt oder Sitzung wählen"
icon={Icon.Circle}
/>
{projects.map((project) => (
<Form.Dropdown.Item
key={project.path}
value={`project:${project.path}`}
title={`Projekt: ${project.name} · ${project.path}`}
icon={Icon.Folder}
/>
))}
{sessions.map((session) => ( {sessions.map((session) => (
<Form.Dropdown.Item <Form.Dropdown.Item
key={session.id} key={session.id}
@@ -140,19 +273,30 @@ export default function Command() {
function DepseudonymizeResult({ function DepseudonymizeResult({
result, result,
session, targetName,
targetSubtitle,
richHtml,
showFormatStatus,
}: { }: {
result: DepseudonymizeResponse; result: DepseudonymizeResponse;
session: VelumSession; targetName: string;
targetSubtitle: string;
richHtml?: string;
showFormatStatus: boolean;
}) { }) {
const markdown = [ const markdown = [
`# ${session.name}`, `# ${targetName}`,
sessionSubtitle(session), targetSubtitle,
"", "",
"## Wiederhergestellter Text", "## Wiederhergestellter Text",
markdownCodeBlock(result.original_text), markdownCodeBlock(result.original_text),
"", "",
`Ersetzungen: ${result.replacements_made}`, `Ersetzungen: ${result.replacements_made}`,
richHtml
? "Formatierung kann beim Kopieren oder Einfügen übernommen werden."
: showFormatStatus
? "Formatierung war nicht verfügbar oder konnte nicht sicher zugeordnet werden; Klartext kann weiterhin kopiert werden."
: "",
].join("\n"); ].join("\n");
return ( return (
@@ -160,12 +304,46 @@ function DepseudonymizeResult({
markdown={markdown} markdown={markdown}
actions={ actions={
<ActionPanel> <ActionPanel>
{richHtml && (
<>
<Action
title="Mit Formatierung kopieren"
icon={Icon.Clipboard}
onAction={async () => {
try {
await copyRichText(richHtml, result.original_text);
await showToast({
style: Toast.Style.Success,
title: "Mit Formatierung kopiert",
});
} catch (error) {
await showToast({
style: Toast.Style.Failure,
title: "Formatierter Text konnte nicht kopiert werden",
message:
error instanceof Error ? error.message : String(error),
});
}
}}
/>
<Action
title="Mit Formatierung einfügen"
icon={Icon.TextCursor}
onAction={() =>
Clipboard.paste({
html: richHtml,
text: result.original_text,
})
}
/>
</>
)}
<Action.CopyToClipboard <Action.CopyToClipboard
title="Wiederhergestellten Text kopieren" title="Als Klartext kopieren"
content={result.original_text} content={result.original_text}
/> />
<Action <Action
title="Wiederhergestellten Text einfügen" title="Als Klartext einfügen"
icon={Icon.TextCursor} icon={Icon.TextCursor}
onAction={() => Clipboard.paste(result.original_text)} onAction={() => Clipboard.paste(result.original_text)}
/> />

149
src/lib/project-review.ts Normal file
View File

@@ -0,0 +1,149 @@
import type { PlaceholderMapping } from "../types";
import { whitelistKey } from "./project-whitelist";
import type { ProjectWhitelistEntry } from "./project-whitelist";
const SEPARATOR = "\n\n\n\n";
export const MAX_BATCH_CODEPOINTS = 24000;
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>): string {
const protectedOffsets = new Set<number>();
for (const term of knownTerms) {
if (!term.includes("_")) continue;
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`, "giu");
for (const match of stem.matchAll(pattern)) {
for (let offset = match.index; offset < match.index + match[0].length; offset++) protectedOffsets.add(offset);
}
}
return stem.replace(/_/g, (_character, offset: number) => 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[] = [];
let segments: BatchSegment[] = [];
let text = "";
let length = 0;
const separatorLength = [...SEPARATOR].length;
const flush = () => {
if (segments.length) batches.push({ text, segments });
segments = [];
text = "";
length = 0;
};
for (const item of items) {
if (!item.text) continue;
const itemLength = [...item.text].length;
if (segments.length && length + separatorLength + itemLength > limit) flush();
if (segments.length) { text += SEPARATOR; length += separatorLength; }
segments.push({ ...item, start: length, end: length + itemLength });
text += item.text;
length += itemLength;
}
flush();
return batches;
}
export function spansBySegment<T extends { start: number; end: number }>(
batch: TextBatch, spans: T[],
): Map<string, T[]> {
const result = new Map<string, T[]>(batch.segments.map((segment) => [segment.key, []]));
let segmentIndex = 0;
for (const span of [...spans].sort((a, b) => a.start - b.start)) {
while (segmentIndex < batch.segments.length && span.start >= batch.segments[segmentIndex].end) segmentIndex++;
const segment = batch.segments[segmentIndex];
if (!segment || span.start < segment.start || span.end > segment.end) continue;
result.get(segment.key)!.push({ ...span, start: span.start - segment.start, end: span.end - segment.start });
}
return result;
}
export type GroupableHit = { id: string; type: string; original: string; placeholder: string };
export type ReviewGroup<T extends GroupableHit> = {
key: string; type: string; original: string; placeholder: string; hits: T[];
};
export function groupReviewHits<T extends GroupableHit>(hits: T[]): ReviewGroup<T>[] {
const groups = new Map<string, ReviewGroup<T>>();
for (const hit of hits) {
const key = JSON.stringify([hit.type, hit.original, hit.placeholder]);
let group = groups.get(key);
if (!group) {
group = { key, type: hit.type, original: hit.original, placeholder: hit.placeholder,
hits: [] };
groups.set(key, group);
}
group.hits.push(hit);
}
return [...groups.values()].sort((a, b) => a.type.localeCompare(b.type) || a.original.localeCompare(b.original));
}
export function withoutWhitelistedHits<T extends GroupableHit>(hits: T[], whitelist: ProjectWhitelistEntry[]): T[] {
const excluded = new Set(whitelist.map(whitelistKey));
return hits.filter((hit) => !excluded.has(whitelistKey(hit)));
}
/** A later run must not turn an existing Velum placeholder plus adjacent text into a new entity. */
export function withoutPlaceholderHits<T extends { original: string; type: string }>(
hits: T[], knownPlaceholders: Iterable<string> = [],
): T[] {
const placeholder = /(?<![\p{L}\p{N}])[A-Z][A-Z0-9]*_\d{2,}(?![\p{L}\p{N}])/u;
const known = [...knownPlaceholders].filter(Boolean);
return hits.filter((hit) => hit.type === "PASSWORT" || (
!placeholder.test(hit.original) && !known.some((key) => hit.original.includes(key))
));
}
export function withoutWhitelistedRules<T extends { type: string; value: string }>(
rules: T[], whitelist: ProjectWhitelistEntry[],
): T[] {
const excluded = new Set(whitelist.map(whitelistKey));
return rules.filter((rule) => !excluded.has(whitelistKey({ type: rule.type, original: rule.value })));
}
export function canonicalPerson(placeholder: string, merges: Record<string, string>): string {
const seen = new Set<string>();
let current = placeholder;
while (merges[current] && !seen.has(current)) {
seen.add(current);
current = merges[current];
}
return current;
}
export function mergePeople(merges: Record<string, string>, source: string, target: string): Record<string, string> {
const from = canonicalPerson(source, merges);
const into = canonicalPerson(target, merges);
if (from === into) return merges;
const next = { ...merges };
for (const person of new Set([...Object.keys(next), ...Object.values(next), from])) {
if (canonicalPerson(person, merges) === from && person !== into) next[person] = into;
}
delete next[into];
return next;
}
export function mappingForSelection(
preview: PlaceholderMapping, previous: PlaceholderMapping, accepted: GroupableHit[],
merges: Record<string, string>,
): PlaceholderMapping {
const keys = new Set([...Object.keys(previous), ...accepted.map((hit) => hit.placeholder)]);
const mapping: PlaceholderMapping = {};
for (const key of keys) {
const entry = preview[key] ?? previous[key];
if (entry) mapping[key] = { ...entry };
}
for (const [source, target] of Object.entries(merges)) {
const root = canonicalPerson(target, merges);
if (source === root || mapping[source]?.type !== "PERSON" || mapping[root]?.type !== "PERSON") {
throw new Error("Ungültige Personen-Zusammenführung.");
}
mapping[source] = { ...mapping[source], merged_into: root };
}
return mapping;
}

189
src/lib/project-revision.ts Normal file
View File

@@ -0,0 +1,189 @@
import { promises as fs } from "node:fs";
import { basename, dirname, join } from "node:path";
import type { PlaceholderMapping } from "../types";
import { canonicalPerson } from "./project-review";
import {
createRun, digest, isRestorableDocument, listRuns, loadProjectWhitelist, publishProjectCopies,
replaceProjectFilesInPlace, resolveWithin, restorePath, restoreText,
saveProjectWhitelist, saveRun,
} from "./velum-project";
import type { ProjectRun, ProjectWhitelistEntry } from "./velum-project";
export type RevisionPlan = {
mapping: PlaceholderMapping;
replacements: PlaceholderMapping;
whitelist: ProjectWhitelistEntry[];
released: string[];
merged: string[];
};
function existingRoot(key: string, mapping: PlaceholderMapping): string {
const seen = new Set<string>();
let current = key;
while (mapping[current]?.merged_into) {
if (seen.has(current)) throw new Error("Zyklische Personen-Zuordnung.");
seen.add(current);
current = mapping[current].merged_into!;
if (!mapping[current] || mapping[current].type !== "PERSON") {
throw new Error("Personen-Zuordnung verweist auf ein ungültiges Ziel.");
}
}
return current;
}
/** Build a single direct substitution for each changed placeholder; no replacement chains. */
export function planProjectRevision(
previous: PlaceholderMapping, selectedRelease: Set<string>, requestedMerges: Record<string, string>,
): RevisionPlan {
for (const key of selectedRelease) if (!previous[key]) throw new Error(`Unbekannter Platzhalter: ${key}`);
const releasedRoots = new Set([...selectedRelease].map((key) => existingRoot(key, previous)));
const released = Object.keys(previous).filter((key) => releasedRoots.has(existingRoot(key, previous)));
const releaseSet = new Set(released);
const roots = new Set(Object.keys(previous).filter((key) =>
previous[key].type === "PERSON" && existingRoot(key, previous) === key && !releaseSet.has(key)));
const merges: Record<string, string> = {};
for (const [source, target] of Object.entries(requestedMerges)) {
if (!roots.has(source) || !roots.has(target) || source === target) {
throw new Error("Zusammenführung benötigt zwei aktive Personen.");
}
merges[source] = target;
}
for (const root of roots) {
const seen = new Set<string>();
let cursor = root;
while (merges[cursor]) {
if (seen.has(cursor)) throw new Error("Zyklische Zusammenführung.");
seen.add(cursor);
cursor = merges[cursor];
}
const final = canonicalPerson(root, merges);
if (!roots.has(final)) throw new Error("Ungültiges Ziel der Zusammenführung.");
}
const mapping: PlaceholderMapping = {};
const replacements: PlaceholderMapping = {};
const merged: string[] = [];
for (const [key, entry] of Object.entries(previous)) {
const oldRoot = entry.type === "PERSON" ? existingRoot(key, previous) : key;
if (releaseSet.has(key)) {
// A canonical placeholder can stand for several spelling variants. Its
// visible text restores to the canonical original; every variant is excluded.
replacements[key] = { original: entry.original, type: entry.type };
continue;
}
const finalRoot = entry.type === "PERSON" ? canonicalPerson(oldRoot, merges) : key;
mapping[key] = { ...entry };
if (entry.type === "PERSON") {
if (finalRoot === key) delete mapping[key].merged_into;
else mapping[key].merged_into = finalRoot;
}
if (finalRoot !== oldRoot) {
replacements[key] = { original: finalRoot, type: entry.type };
merged.push(key);
}
}
// A saved alias may still occur in older output files. Rewrite it together
// with its former canonical root when that root is merged now.
return {
mapping, replacements,
whitelist: released.map((key) => ({ type: previous[key].type, original: previous[key].original })),
released, merged,
};
}
export async function reviseProjectRun(args: {
previous: ProjectRun;
previousDirectory: string;
release: Set<string>;
merges: Record<string, string>;
rewriteDocument: (filename: string, data: Buffer, replacements: PlaceholderMapping) => Promise<Buffer>;
support?: string;
}): Promise<{ run: ProjectRun; directory: string }> {
const { previous, previousDirectory, rewriteDocument, support } = args;
const latest = (await listRuns(previous.projectPath, support))[0];
if (latest?.run.id !== previous.id) {
throw new Error("Es gibt bereits einen neueren Projektstand. Bitte die Liste aktualisieren.");
}
const plan = planProjectRevision(previous.mapping, args.release, args.merges);
if (!plan.released.length && !plan.merged.length) throw new Error("Bitte mindestens einen Begriff freigeben oder Personen zusammenführen.");
const inPlace = Boolean(previous.inPlace || previous.sourceInPlace);
const previousWhitelist = plan.whitelist.length
? await loadProjectWhitelist(previous.projectPath, support) : [];
const created = await createRun(previous.projectPath, support);
const { directory, run } = created;
run.origin = previous.origin;
run.revisedFrom = previous.id;
run.commentAuthorCoverage = previous.commentAuthorCoverage;
run.reviewAuthorCoverage = previous.reviewAuthorCoverage;
run.mapping = plan.mapping;
run.matchModes = Object.fromEntries(Object.entries(previous.matchModes ?? {})
.filter(([key]) => key in plan.mapping));
run.sourceInPlace = inPlace;
const usedPaths = new Set<string>();
const hashes = new Map<string, string>();
// "anywhere" placeholders from imported code projects can be prefixes of
// other placeholders. Match unchanged identifiers first as identity entries.
const textReplacements: PlaceholderMapping = Object.fromEntries(Object.entries(previous.mapping)
.filter(([key]) => !(key in plan.replacements))
.map(([key, entry]) => [key, { original: key, type: entry.type }]));
Object.assign(textReplacements, plan.replacements);
let replacementAttempted = false;
let whitelistChanged = false;
let publishedCopyPath: string | undefined;
try {
for (const file of previous.files) {
const source = resolveWithin(join(previousDirectory, "files"), file.output);
if (!(await fs.lstat(source)).isFile()) throw new Error(`Keine reguläre Datei im Projektstand: ${file.output}`);
const data = await fs.readFile(source);
hashes.set(file.output, digest(data));
const outputPath = restorePath(file.output, textReplacements, previous.matchModes);
if (usedPaths.has(outputPath)) throw new Error(`Doppelter Dateiname nach Änderung: ${outputPath}`);
usedPaths.add(outputPath);
const target = resolveWithin(join(directory, "files"), outputPath);
await fs.mkdir(dirname(target), { recursive: true, mode: 0o700 });
const output = file.kind === "document" && isRestorableDocument(file.output)
? await rewriteDocument(basename(file.output), data, plan.replacements)
: file.kind === "document" ? data
: Buffer.from(restoreText(data.toString("utf8"), textReplacements, previous.matchModes), "utf8");
await fs.writeFile(target, output, { flag: "wx", mode: 0o600 });
run.files.push({ source: file.output, output: outputPath, kind: file.kind });
}
if (plan.whitelist.length) {
await saveProjectWhitelist(previous.projectPath, [...previousWhitelist, ...plan.whitelist], support);
whitelistChanged = true;
}
if (inPlace) {
// The source folder must still be exactly the snapshot being revised.
// This prevents a later local edit from being silently overwritten.
for (const file of previous.files) {
const current = resolveWithin(previous.projectPath, file.output);
if (!(await fs.lstat(current)).isFile() || digest(await fs.readFile(current)) !== hashes.get(file.output)) {
throw new Error(`Projektdatei wurde seit dem letzten Stand geändert: ${file.output}`);
}
}
run.inPlace = true;
await saveRun(directory, run);
replacementAttempted = true;
await replaceProjectFilesInPlace(previous.projectPath, directory, run.files);
} else {
publishedCopyPath = await publishProjectCopies(run, directory);
run.copyPath = publishedCopyPath;
await saveRun(directory, run);
}
} catch (error) {
// Keep the backup if replacing the project folder was attempted, even when
// its own rollback reports an error.
if (!replacementAttempted) {
await fs.rm(directory, { recursive: true, force: true });
if (publishedCopyPath) await fs.rm(publishedCopyPath, { recursive: true, force: true });
} else await fs.rm(join(directory, "run.json"), { force: true });
if (whitelistChanged) {
try { await saveProjectWhitelist(previous.projectPath, previousWhitelist, support); }
catch (rollbackError) {
throw new Error(`${(error as Error).message} Die Ausnahmeliste konnte nicht zurückgesetzt werden: ${(rollbackError as Error).message}`);
}
}
if (replacementAttempted) throw new Error(`${(error as Error).message} Sicherung des vorigen Projektstands: ${directory}`);
throw error;
}
return { run, directory };
}

View File

@@ -0,0 +1,5 @@
export type ProjectWhitelistEntry = { type: string; original: string };
export function whitelistKey(entry: ProjectWhitelistEntry): string {
return JSON.stringify([entry.type.toUpperCase(), entry.original.trim().toLocaleLowerCase("de")]);
}

17
src/lib/restore-case.ts Normal file
View File

@@ -0,0 +1,17 @@
/** Keep the saved spelling for the canonical placeholder and adapt edited case variants. */
export function restoreOriginalCase(match: string, key: string, original: string): string {
if (match === key) return original;
const letters = match.replace(/[^\p{L}]/gu, "");
if (!letters) return original;
if (letters === letters.toLocaleLowerCase("de")) return original.toLocaleLowerCase("de");
if (letters === letters.toLocaleUpperCase("de")) return original.toLocaleUpperCase("de");
if (/^\p{Lu}\p{Ll}+$/u.test(letters)) {
const originalLetters = original.replace(/[^\p{L}]/gu, "");
if (originalLetters && (originalLetters === originalLetters.toLocaleLowerCase("de")
|| originalLetters === originalLetters.toLocaleUpperCase("de"))) {
return original.toLocaleLowerCase("de").replace(/(^|[^\p{L}])(\p{L})/gu,
(_, prefix: string, letter: string) => prefix + letter.toLocaleUpperCase("de"));
}
}
return original;
}

195
src/lib/rich-text.ts Normal file
View File

@@ -0,0 +1,195 @@
import type { PlaceholderMapping } from "../types";
import type { ProjectRun } from "./velum-project";
import { restoreOriginalCase } from "./restore-case";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function decodeEntities(value: string): string {
const named: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
ndash: "–",
mdash: "—",
hellip: "…",
rsquo: "’",
lsquo: "‘",
rdquo: "”",
ldquo: "“",
};
return value.replace(
/&(#x[0-9a-f]+|#[0-9]+|[a-z][a-z0-9]+);/gi,
(entity, code: string) => {
if (code.startsWith("#")) {
const point =
code[1].toLowerCase() === "x"
? Number.parseInt(code.slice(2), 16)
: Number.parseInt(code.slice(1), 10);
return Number.isInteger(point) && point >= 0 && point <= 0x10ffff
? String.fromCodePoint(point)
: entity;
}
return named[code.toLowerCase()] ?? entity;
},
);
}
function normalized(value: string): string {
return value
.replace(/\u00a0/g, " ")
.replace(/\s+/g, " ")
.trim();
}
type HtmlToken = { kind: "tag" | "text"; value: string };
function tokenizeHtml(html: string): HtmlToken[] | null {
const tokens: HtmlToken[] = [];
for (let start = 0; start < html.length; ) {
if (html[start] !== "<") {
const end = html.indexOf("<", start);
const next = end < 0 ? html.length : end;
tokens.push({ kind: "text", value: html.slice(start, next) });
start = next;
continue;
}
if (html.startsWith("<!--", start)) {
const end = html.indexOf("-->", start + 4);
if (end < 0) return null;
tokens.push({ kind: "tag", value: html.slice(start, end + 3) });
start = end + 3;
continue;
}
let quote = "";
let end = start + 1;
for (; end < html.length; end++) {
const character = html[end];
if (quote) {
if (character === quote) quote = "";
} else if (character === '"' || character === "'") quote = character;
else if (character === ">") break;
}
if (end === html.length) return null;
tokens.push({ kind: "tag", value: html.slice(start, end + 1) });
start = end + 1;
}
return tokens;
}
type Match = { start: number; end: number; replacement: string };
function replaceNode(
value: string,
mapping: PlaceholderMapping,
matchModes: ProjectRun["matchModes"],
session: boolean,
): { value: string; count: number } {
const candidates: Match[] = [];
const foldedCounts = new Map<string, number>();
if (!session) {
for (const key of Object.keys(mapping)) {
const folded = key.toUpperCase();
foldedCounts.set(folded, (foldedCounts.get(folded) ?? 0) + 1);
}
}
for (const [key, entry] of Object.entries(mapping)) {
const stripped = key.replace(/[<>]/g, "");
const bare = session ? stripped : key;
const boundary =
matchModes?.[key] === "anywhere"
? escapeRegExp(bare)
: `(?<![\\p{L}\\p{N}])${escapeRegExp(bare)}(?![\\p{L}\\p{N}])`;
const patterns = [new RegExp(boundary, session ? "gu" : "giu")];
if (session) {
patterns.unshift(
new RegExp(
`(?:&lt;|&#0*60;|&#x0*3c;)${escapeRegExp(stripped)}(?:&gt;|&#0*62;|&#x0*3e;)`,
"giu",
),
);
}
for (const pattern of patterns) {
for (const match of value.matchAll(pattern)) {
if (!session && match[0] !== key && (Object.prototype.hasOwnProperty.call(mapping, match[0])
|| foldedCounts.get(key.toUpperCase()) !== 1)) continue;
candidates.push({
start: match.index,
end: match.index + match[0].length,
replacement: escapeHtml(session ? entry.original : restoreOriginalCase(match[0], key, entry.original)),
});
}
}
}
candidates.sort((a, b) => a.start - b.start || b.end - a.end);
let output = "";
let cursor = 0;
let count = 0;
for (const match of candidates) {
if (match.start < cursor) continue;
output += value.slice(cursor, match.start) + match.replacement;
cursor = match.end;
count++;
}
return { value: output + value.slice(cursor), count };
}
/** Keep the source markup untouched except for verified placeholder text nodes. */
export function restoreRichHtml(
html: string,
sourceText: string,
restoredText: string,
mapping: PlaceholderMapping,
matchModes?: ProjectRun["matchModes"],
session = false,
): string | undefined {
const tokens = tokenizeHtml(html);
if (!tokens) return undefined;
const ignored = new Set(["head", "script", "style", "title", "noscript"]);
const blocks = new Set(["p", "div", "li", "tr", "h1", "h2", "h3", "br"]);
let ignoredDepth = 0;
let before = "";
let after = "";
let output = "";
for (const token of tokens) {
if (token.kind === "tag") {
const tag = /^<\s*(\/?)\s*([a-z][\w:-]*)/i.exec(token.value);
if (tag) {
const name = tag[2].toLowerCase();
if (ignored.has(name))
ignoredDepth = Math.max(0, ignoredDepth + (tag[1] ? -1 : 1));
if (!ignoredDepth && blocks.has(name) && (tag[1] || name === "br")) {
before += " ";
after += " ";
}
}
output += token.value;
continue;
}
if (ignoredDepth) {
output += token.value;
continue;
}
before += decodeEntities(token.value);
const replaced = replaceNode(token.value, mapping, matchModes, session);
after += decodeEntities(replaced.value);
output += replaced.value;
}
return normalized(before) === normalized(sourceText) &&
normalized(after) === normalized(restoredText)
? output
: undefined;
}

529
src/lib/velum-project.ts Normal file
View File

@@ -0,0 +1,529 @@
import { createHash, randomUUID } from "node:crypto";
import { constants as fsConstants, promises as fs } from "node:fs";
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
import { environment } from "@raycast/api";
import type { EntityType, PlaceholderMapping } from "../types";
import { whitelistKey } from "./project-whitelist";
import type { ProjectWhitelistEntry } from "./project-whitelist";
import { restoreOriginalCase } from "./restore-case";
export { whitelistKey } from "./project-whitelist";
export type { ProjectWhitelistEntry } from "./project-whitelist";
export type ProjectRule = { type: "BEGRIFF" | "PERSON" | "ORG" | "ORT" | "FIRMA" | "KUNDE"; value: string };
export type ProjectFile = { relPath: string; absPath: string; kind: "text" | "document"; size: number };
export type RunFile = { source: string; output: string; kind: ProjectFile["kind"] };
export type ProjectRun = {
version: 2;
origin?: "direct" | "imported";
id: string;
projectPath: string;
projectName: string;
createdAt: string;
mapping: PlaceholderMapping;
/** Generic replacement mode for placeholders embedded in code identifiers. */
matchModes?: Record<string, "token" | "anywhere">;
files: RunFile[];
inPlace?: boolean;
/** The project source already contains outputs of an earlier in-place run. */
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;
/** Word tracked-change authors and deleted text were reviewed. */
reviewAuthorCoverage?: boolean;
/** Visible copy next to the source project; immutable snapshots remain in Raycast support. */
copyPath?: string;
};
const DOCUMENTS = new Set([".docx", ".xlsx", ".pptx", ".pdf"]);
const RESTORABLE_DOCUMENTS = new Set([".docx", ".xlsx", ".pptx"]);
const SECRET_EXTENSIONS = new Set([".pem", ".key", ".p12", ".pfx"]);
const EXCLUDES = new Set([".git", "node_modules", "dist", "build", "out", ".next", ".nuxt",
".venv", "venv", "__pycache__", "target", "vendor", "coverage", ".cache", ".turbo",
".idea", ".vscode", "Pods", "DerivedData"]);
export function canonicalProjectPath(path: string): string {
return resolve(path);
}
function projectIdFor(path: string): string {
return createHash("sha1").update(canonicalProjectPath(path)).digest("hex").slice(0, 16);
}
async function isBinary(path: string): Promise<boolean> {
const file = await fs.open(path, "r");
try {
const buffer = Buffer.alloc(8192);
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
return buffer.subarray(0, bytesRead).includes(0);
} finally { await file.close(); }
}
export function projectDataRoot(projectPath: string, support = environment.supportPath): string {
return join(support, "velum-projects", projectIdFor(projectPath));
}
export function rulesPath(projectPath: string, support = environment.supportPath): string {
return join(projectDataRoot(projectPath, support), "rules.json");
}
export function whitelistPath(projectPath: string, support = environment.supportPath): string {
return join(projectDataRoot(projectPath, support), "whitelist.json");
}
export function entityTypesPath(projectPath: string, support = environment.supportPath): string {
return join(projectDataRoot(projectPath, support), "entity-types.json");
}
export async function loadProjectEntityTypes(projectPath: string, support?: string): Promise<EntityType[] | null> {
try {
const value = JSON.parse(await fs.readFile(entityTypesPath(projectPath, support), "utf8")) as unknown;
if (!Array.isArray(value) || value.some((type) => typeof type !== "string" || !type)) {
throw new Error("Ungültige Kategorienauswahl für dieses Projekt.");
}
return [...new Set(value)];
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
export async function saveProjectEntityTypes(projectPath: string, types: EntityType[], support?: string): Promise<void> {
const path = entityTypesPath(projectPath, support);
await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
const temp = `${path}.${randomUUID()}.tmp`;
try {
await fs.writeFile(temp, JSON.stringify([...new Set(types)]), { encoding: "utf8", mode: 0o600 });
await fs.rename(temp, path);
} catch (error) {
await fs.rm(temp, { force: true });
throw error;
}
}
export async function loadProjectWhitelist(projectPath: string, support?: string): Promise<ProjectWhitelistEntry[]> {
try {
const value = JSON.parse(await fs.readFile(whitelistPath(projectPath, support), "utf8")) as unknown;
if (!Array.isArray(value)) throw new Error("Ungültige Projekt-Whitelist.");
return value.filter((entry): entry is ProjectWhitelistEntry => Boolean(entry) && typeof entry === "object"
&& typeof entry.type === "string" && typeof entry.original === "string"
&& entry.type.length > 0 && entry.original.trim().length > 0);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}
}
export async function saveProjectWhitelist(projectPath: string, entries: ProjectWhitelistEntry[], support?: string): Promise<void> {
const unique = [...new Map(entries.map((entry) => [whitelistKey(entry),
{ type: entry.type.toUpperCase(), original: entry.original.trim() }])).values()];
const path = whitelistPath(projectPath, support);
await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
const temp = `${path}.${randomUUID()}.tmp`;
try {
await fs.writeFile(temp, JSON.stringify(unique), { encoding: "utf8", mode: 0o600 });
await fs.rename(temp, path);
} catch (error) {
await fs.rm(temp, { force: true });
throw error;
}
}
export async function loadProjectRules(projectPath: string, support?: string): Promise<ProjectRule[]> {
try {
const value = JSON.parse(await fs.readFile(rulesPath(projectPath, support), "utf8")) as unknown;
return Array.isArray(value) ? value.filter(isProjectRule) : [];
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}
}
function isProjectRule(value: unknown): value is ProjectRule {
if (!value || typeof value !== "object") return false;
const rule = value as Record<string, unknown>;
return ["BEGRIFF", "PERSON", "ORG", "ORT", "FIRMA", "KUNDE"].includes(String(rule.type))
&& typeof rule.value === "string" && rule.value.trim().length >= 2;
}
export function parseProjectRules(input: string): ProjectRule[] {
const rules = input.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => {
const match = /^(BEGRIFF|PERSON|ORG|ORT|FIRMA|KUNDE):\s*(.+)$/i.exec(line);
return match
? { type: match[1].toUpperCase() as ProjectRule["type"], value: match[2].trim() }
: { type: "BEGRIFF" as const, value: line };
});
if (rules.length > 500 || rules.some((rule) => rule.value.length < 2 || rule.value.length > 200)) {
throw new Error("Maximal 500 Begriffe mit jeweils 2 bis 200 Zeichen sind erlaubt.");
}
return rules.filter((rule, index) => rules.findIndex(
(other) => other.type === rule.type && other.value.toLocaleLowerCase() === rule.value.toLocaleLowerCase(),
) === index);
}
export async function saveProjectRules(projectPath: string, rules: ProjectRule[], support?: string): Promise<void> {
const path = rulesPath(projectPath, support);
await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
await fs.writeFile(path, JSON.stringify(rules), { encoding: "utf8", mode: 0o600 });
}
export function forceMasks(rules: ProjectRule[]): Record<string, string[]> {
const result: Record<string, string[]> = {};
for (const rule of rules) (result[rule.type] ??= []).push(rule.value);
return result;
}
export async function scanProjectFiles(root: string, maxSize = 20 * 1024 * 1024): Promise<{
files: ProjectFile[]; skipped: string[];
}> {
const files: ProjectFile[] = [];
const skipped: string[] = [];
async function walk(directory: string): Promise<void> {
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
const relPath = relative(root, path).split(sep).join("/");
if (entry.isSymbolicLink()) { skipped.push(relPath); continue; }
if (entry.isDirectory()) {
if (EXCLUDES.has(entry.name)) { skipped.push(relPath); continue; }
await walk(path);
continue;
}
if (!entry.isFile()) continue;
if (entry.name === ".env" || entry.name.startsWith(".env.") || SECRET_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
skipped.push(relPath);
continue;
}
const stat = await fs.stat(path);
if (stat.size > maxSize) { skipped.push(relPath); continue; }
const kind = DOCUMENTS.has(extname(path).toLowerCase()) ? "document" : "text";
if (kind === "text") {
if (await isBinary(path)) { skipped.push(relPath); continue; }
try { new TextDecoder("utf-8", { fatal: true }).decode(await fs.readFile(path)); }
catch { skipped.push(relPath); continue; }
}
files.push({ relPath, absPath: path, kind, size: stat.size });
}
}
await walk(root);
files.sort((a, b) => a.relPath.localeCompare(b.relPath));
return { files, skipped };
}
export function digest(data: Buffer): string {
return createHash("sha256").update(data).digest("hex");
}
/** Python API spans count Unicode code points; JavaScript strings use UTF-16 offsets. */
export function toUtf16Offsets<T extends { start: number; end: number }>(text: string, spans: T[]): T[] {
const positions = [0];
let offset = 0;
for (const character of text) {
offset += character.length;
positions.push(offset);
}
return spans.map((span) => {
if (!Number.isInteger(span.start) || !Number.isInteger(span.end)
|| span.start < 0 || span.end > positions.length - 1 || span.start >= span.end) {
throw new Error("Ungültige Trefferposition der Velum-API.");
}
return { ...span, start: positions[span.start], end: positions[span.end] };
});
}
export function replaceSpans(
source: string,
spans: Array<{ start: number; end: number; placeholder: string; id: string }>,
accepted: Set<string>,
): string {
let output = source;
for (const span of [...spans].filter((item) => accepted.has(item.id)).sort((a, b) => b.start - a.start)) {
if (span.start < 0 || span.end > source.length || span.start >= span.end) throw new Error("Ungültige Trefferposition.");
output = output.slice(0, span.start) + span.placeholder + output.slice(span.end);
}
return output;
}
function restorePattern(mapping: PlaceholderMapping, matchModes: ProjectRun["matchModes"], pathSegment = false): {
pattern: RegExp; originalFor: (match: string) => string | undefined;
} | undefined {
const keys = Object.keys(mapping).sort((a, b) => b.length - a.length);
if (!keys.length) return undefined;
const foldedKeys = new Map<string, string | null>();
for (const key of keys) {
const folded = key.toUpperCase();
if (foldedKeys.has(folded)) foldedKeys.set(folded, null);
else foldedKeys.set(folded, key);
}
const parts = keys.map((key) => {
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return pathSegment || matchModes?.[key] === "anywhere"
? escaped : `(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`;
});
return {
pattern: new RegExp(parts.join("|"), "giu"),
originalFor: (match) => {
const key = Object.prototype.hasOwnProperty.call(mapping, match)
? match : foldedKeys.get(match.toUpperCase());
return key ? restoreOriginalCase(match, key, mapping[key].original) : undefined;
},
};
}
export function restoreTextWithCount(source: string, mapping: PlaceholderMapping,
matchModes?: ProjectRun["matchModes"]): { text: string; replacements: number } {
const pattern = restorePattern(mapping, matchModes);
let replacements = 0;
const text = pattern ? source.replace(pattern.pattern, (match) => {
const original = pattern.originalFor(match);
if (original === undefined) return match;
replacements++;
return original;
}) : source;
return { text, replacements };
}
export function restoreText(source: string, mapping: PlaceholderMapping, matchModes?: ProjectRun["matchModes"]): string {
return restoreTextWithCount(source, mapping, matchModes).text;
}
export function restorePath(relPath: string, mapping: PlaceholderMapping, matchModes?: ProjectRun["matchModes"]): string {
// Filenames can join a placeholder directly to another identifier fragment.
// Restricting replacements to word boundaries would leave those names masked.
const pattern = restorePattern(mapping, matchModes, true);
const segments = safeRelativePath(relPath).split("/").map((segment) => {
const restored = pattern ? segment.replace(pattern.pattern, (match) => pattern.originalFor(match) ?? match) : segment;
if (restored.includes("/") || restored.includes("\\") || restored === "." || restored === "..") {
throw new Error("Rückübersetzter Dateiname enthält einen ungültigen Pfadteil.");
}
return restored;
});
return safeRelativePath(segments.join("/"));
}
export function safeRelativePath(path: string): string {
const normalized = path.split("\\").join("/");
if (!normalized || normalized.startsWith("/") || normalized.split("/").some((part) => part === ".." || part === "." || !part)) {
throw new Error("Ungültiger Ausgabepfad.");
}
return normalized;
}
export async function createRun(projectPath: string, support = environment.supportPath): Promise<{
run: ProjectRun; directory: string;
}> {
projectPath = canonicalProjectPath(projectPath);
const id = `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
const directory = join(projectDataRoot(projectPath, support), "runs", id);
await fs.mkdir(join(directory, "files"), { recursive: true, mode: 0o700 });
return {
directory,
run: { version: 2, origin: "direct", id, projectPath, projectName: basename(projectPath),
createdAt: new Date().toISOString(), mapping: {}, files: [] },
};
}
export async function saveRun(directory: string, run: ProjectRun): Promise<void> {
const dest = join(directory, "run.json");
const tmp = `${dest}.tmp`;
await fs.writeFile(tmp, JSON.stringify(run), { encoding: "utf8", mode: 0o600 });
await fs.rename(tmp, dest);
}
export function runCopiesPath(run: ProjectRun, directory: string): string {
return run.copyPath ?? join(directory, "files");
}
export async function scanRestorableCopyFiles(run: ProjectRun, directory: string, current = true): Promise<{
root: string; files: ProjectFile[]; skipped: number;
}> {
const inPlace = Boolean(run.inPlace || run.sourceInPlace);
let root = current && inPlace ? canonicalProjectPath(run.projectPath)
: current ? runCopiesPath(run, directory) : join(directory, "files");
if (current && !inPlace && run.copyPath) {
try {
if (!(await fs.lstat(root)).isDirectory()) throw new Error("Kopienpfad ist kein regulärer Ordner.");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
root = join(directory, "files");
}
}
const scan = await scanProjectFiles(root);
const files = scan.files.filter((file) => file.kind === "text" || isRestorableDocument(file.relPath));
return { root, files, skipped: scan.skipped.length + scan.files.length - files.length };
}
/** Publish only output files next to the project, without the private mapping. */
export async function publishProjectCopies(run: ProjectRun, directory: string): Promise<string> {
const projectPath = canonicalProjectPath(run.projectPath);
const parent = dirname(projectPath);
const target = join(parent, `${basename(projectPath)}-Velum-${run.id}`);
try {
await fs.lstat(target);
throw new Error(`Kopienordner bereits vorhanden: ${target}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const staging = await fs.mkdtemp(join(parent, `.${basename(projectPath)}-Velum-`));
try {
for (const file of run.files) {
const source = resolveWithin(join(directory, "files"), file.output);
if (!(await fs.lstat(source)).isFile()) throw new Error(`Keine reguläre Ausgabedatei: ${file.output}`);
const destination = resolveWithin(staging, file.output);
await fs.mkdir(dirname(destination), { recursive: true, mode: 0o700 });
await fs.copyFile(source, destination, fsConstants.COPYFILE_EXCL);
await fs.chmod(destination, 0o600);
}
await fs.rename(staging, target);
return target;
} catch (error) {
await fs.rm(staging, { recursive: true, force: true });
throw error;
}
}
export async function listRuns(projectPath: string, support = environment.supportPath): Promise<Array<{
run: ProjectRun; directory: string;
}>> {
const root = join(projectDataRoot(projectPath, support), "runs");
let entries;
try { entries = await fs.readdir(root, { withFileTypes: true }); }
catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}
const runs = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const directory = join(root, entry.name);
try {
const run = JSON.parse(await fs.readFile(join(directory, "run.json"), "utf8")) as ProjectRun;
if (run.version === 2 && canonicalProjectPath(run.projectPath) === canonicalProjectPath(projectPath)) {
runs.push({ run, directory });
}
} catch { /* incomplete run: not offered for restoration */ }
}
return runs.sort((a, b) => b.run.createdAt.localeCompare(a.run.createdAt));
}
/** An unchanged output from an in-place run can be carried into the next snapshot. */
export async function matchesPreviousOutput(directory: string, relPath: string, currentHash: string): Promise<boolean> {
try {
const previous = await fs.readFile(resolveWithin(join(directory, "files"), relPath));
return digest(previous) === currentHash;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}
export async function listAllRuns(support = environment.supportPath): Promise<Array<{
run: ProjectRun; directory: string;
}>> {
const root = join(support, "velum-projects");
let projects;
try { projects = await fs.readdir(root, { withFileTypes: true }); }
catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}
const results = [];
for (const project of projects) {
if (!project.isDirectory()) continue;
const runsDir = join(root, project.name, "runs");
let entries;
try { entries = await fs.readdir(runsDir, { withFileTypes: true }); }
catch { continue; }
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const directory = join(runsDir, entry.name);
try {
const run = JSON.parse(await fs.readFile(join(directory, "run.json"), "utf8")) as ProjectRun;
if (run.version === 2) results.push({ run, directory });
} catch { /* incomplete run */ }
}
}
return results.sort((a, b) => b.run.createdAt.localeCompare(a.run.createdAt));
}
export function isRestorableDocument(filename: string): boolean {
return RESTORABLE_DOCUMENTS.has(extname(filename).toLowerCase());
}
export function resolveWithin(root: string, relPath: string): string {
const target = resolve(root, safeRelativePath(relPath));
if (!target.startsWith(resolve(root) + sep)) throw new Error("Ausgabepfad verlässt den Zielordner.");
return target;
}
async function assertNoSymlinkParents(root: string, relPath: string): Promise<void> {
const parts = safeRelativePath(relPath).split("/");
let parent = resolve(root);
for (const part of parts.slice(0, -1)) {
parent = join(parent, part);
try {
if ((await fs.lstat(parent)).isSymbolicLink()) throw new Error(`Symbolischer Link im Ausgabepfad: ${relPath}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
}
/** Replaces selected originals only after every output exists and a private backup is complete. */
export async function replaceProjectFilesInPlace(projectPath: string, runDirectory: string, files: RunFile[]): Promise<void> {
const root = resolve(projectPath);
const sourcePaths = new Set(files.map((file) => resolveWithin(root, file.source)));
const targetPaths = new Set<string>();
const sourceModes = new Map<string, number>();
for (const file of files) {
const source = resolveWithin(root, file.source);
const target = resolveWithin(root, file.output);
if (targetPaths.has(target)) throw new Error(`Doppelter Zielname: ${file.output}`);
targetPaths.add(target);
await assertNoSymlinkParents(root, file.output);
const sourceInfo = await fs.lstat(source);
if (!sourceInfo.isFile()) throw new Error(`Quelldatei ist keine reguläre Datei: ${file.source}`);
sourceModes.set(source, sourceInfo.mode & 0o777);
try {
await fs.lstat(target);
if (!sourcePaths.has(target)) throw new Error(`Zielname bereits vorhanden: ${file.output}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
await fs.access(resolveWithin(join(runDirectory, "files"), file.output));
}
const backupRoot = join(runDirectory, "backup");
for (const file of files) {
const backup = resolveWithin(backupRoot, file.source);
await fs.mkdir(dirname(backup), { recursive: true, mode: 0o700 });
await fs.copyFile(resolveWithin(root, file.source), backup, fsConstants.COPYFILE_EXCL);
await fs.chmod(backup, 0o600);
}
const written: string[] = [];
try {
for (const file of files) await fs.unlink(resolveWithin(root, file.source));
for (const file of files) {
const target = resolveWithin(root, file.output);
await fs.mkdir(dirname(target), { recursive: true, mode: 0o700 });
await fs.copyFile(resolveWithin(join(runDirectory, "files"), file.output), target, fsConstants.COPYFILE_EXCL);
written.push(target);
await fs.chmod(target, sourceModes.get(resolveWithin(root, file.source)) ?? 0o600);
}
} catch (error) {
for (const target of written) await fs.rm(target, { force: true });
for (const file of files) {
const source = resolveWithin(root, file.source);
try { await fs.lstat(source); }
catch (missing) {
if ((missing as NodeJS.ErrnoException).code !== "ENOENT") throw missing;
await fs.mkdir(dirname(source), { recursive: true, mode: 0o700 });
await fs.copyFile(resolveWithin(backupRoot, file.source), source, fsConstants.COPYFILE_EXCL);
await fs.chmod(source, sourceModes.get(source) ?? 0o600);
}
}
throw new Error(`Ersetzen fehlgeschlagen; Originale wurden wiederhergestellt. Sicherung: ${backupRoot}. ${(error as Error).message}`);
}
}

View File

@@ -25,6 +25,7 @@ import {
} from "./sessions"; } from "./sessions";
import type { VelumSession } from "./types"; import type { VelumSession } from "./types";
import { mappingSummary, sessionSubtitle } from "./ui"; import { mappingSummary, sessionSubtitle } from "./ui";
import { TranslationTable } from "./show-translation-tables";
export default function Command() { export default function Command() {
const [sessions, setSessions] = useState<VelumSession[]>([]); const [sessions, setSessions] = useState<VelumSession[]>([]);
@@ -145,6 +146,11 @@ export default function Command() {
accessories={[{ text: `${countMappingEntries(session.mapping)}` }]} accessories={[{ text: `${countMappingEntries(session.mapping)}` }]}
actions={ actions={
<ActionPanel> <ActionPanel>
<Action.Push
title="Übersetzungstabelle anzeigen"
icon={Icon.List}
target={<TranslationTable title={session.name} mapping={session.mapping} />}
/>
<Action <Action
title="Als Aktive Sitzung Setzen" title="Als Aktive Sitzung Setzen"
icon={Icon.CheckCircle} icon={Icon.CheckCircle}

View File

@@ -0,0 +1,8 @@
import { Action, ActionPanel, Detail, environment } from "@raycast/api";
export default function Command() {
return <Detail markdown={"Der eigenständige Migrationsbefehl benötigt diesen Velum-Datenordner als `--target-support`. Er verarbeitet die alten Tabellen lokal; Velum selbst greift nicht auf die frühere Erweiterung zu."}
actions={<ActionPanel>
<Action.CopyToClipboard title="Velum-Datenordner kopieren" content={environment.supportPath} />
</ActionPanel>} />;
}

View File

@@ -0,0 +1,601 @@
import { Action, ActionPanel, Alert, Color, confirmAlert, Form, getSelectedFinderItems, Icon, List, showInFinder, showToast, Toast, useNavigation } from "@raycast/api";
import { promises as fs } from "node:fs";
import { basename, dirname, extname, join } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { useEffect, useMemo, useState } from "react";
import type { EntityType, PlaceholderMapping } from "./types";
import { getEntityTypes, pseudonymize, pseudonymizeDocument, requireProjectCapabilities } from "./velum";
import type { DocumentHit } from "./velum";
import {
createRun, digest, forceMasks, listRuns, loadProjectEntityTypes, loadProjectRules, parseProjectRules, publishProjectCopies, replaceSpans,
loadProjectWhitelist, matchesPreviousOutput, replaceProjectFilesInPlace, resolveWithin, safeRelativePath, saveProjectRules,
saveProjectEntityTypes, saveProjectWhitelist, saveRun, scanProjectFiles, toUtf16Offsets, whitelistKey,
} from "./lib/velum-project";
import type { ProjectFile, ProjectRule, ProjectWhitelistEntry, RunFile } from "./lib/velum-project";
import {
canonicalPerson, filenameScanText, groupReviewHits, makeTextBatches, mappingForSelection, mergePeople, spansBySegment,
withoutPlaceholderHits, withoutWhitelistedHits, withoutWhitelistedRules,
} from "./lib/project-review";
const run = promisify(execFile);
const MAX_REVIEW_HITS = 50000;
type Hit = {
id: string; file: string; source: string; original: string; placeholder: string;
type: string; score: number; context: string; start: number; end: number;
kind: "content" | "path";
};
type Draft = { file: ProjectFile; hash: string; hits: Hit[] };
type PathDraft = { key: string; value: string; hits: Hit[] };
type Preview = { drafts: Draft[]; retained: Array<{ file: ProjectFile; hash: string }>;
paths: PathDraft[]; mapping: PlaceholderMapping; previousMapping: PlaceholderMapping;
matchModes?: Record<string, "token" | "anywhere">; hits: Hit[]; entityTypes: EntityType[];
skipped: number; sourceInPlace: boolean };
const typeLabels: Record<string, string> = {
PERSON: "Personen", ORT: "Orte", ORG: "Organisationen", FIRMA: "Firmen",
KUNDE: "Kunden", ADRESSE: "Adressen", BEGRIFF: "Eigene Begriffe",
EMAIL: "E-Mail-Adressen", TELEFON: "Telefonnummern", IBAN: "IBAN",
UID: "UID-Nummern", FIRMENBUCH: "Firmenbuchnummern", SVNR: "Sozialversicherungsnummern",
PASSWORT: "Passwörter", BENUTZER: "Benutzerkennungen", PROJEKT: "Projektbezeichnungen",
DATUM: "Datumsangaben", REFERENZ: "Referenzen", KARTE: "Kreditkarten",
IP: "IP-Adressen", URL: "Internetadressen", DVR: "DVR-Nummern", GISA: "GISA-Nummern",
};
const labelForType = (type: string) => typeLabels[type] ?? type;
async function finderFolder(): Promise<string | undefined> {
try {
// The user asked for the open Finder folder, even when a file inside it is selected.
const { stdout } = await run("/usr/bin/osascript", ["-e",
'tell application "Finder" to get POSIX path of (target of front window as alias)',
]);
const path = stdout.trim();
if (path && (await fs.stat(path)).isDirectory()) return path;
} catch { /* Finder may have no open window or Automation permission */ }
try {
const items = await getSelectedFinderItems();
if (items.length) {
const first = items[0].path;
return (await fs.stat(first)).isDirectory() ? first : dirname(first);
}
} catch { /* Finder selection may be empty or unavailable */ }
return undefined;
}
function textFromBytes(data: Buffer): string {
return new TextDecoder("utf-8", { fatal: true }).decode(data);
}
function contentHits(file: ProjectFile, spans: Array<{
start: number; end: number; type: string; score: number; placeholder: string; original: string;
}>, text: string): Hit[] {
return toUtf16Offsets(text, spans).map((span, index) => ({
...span, id: `${file.relPath}:content:${index}`, file: file.relPath,
source: file.relPath, kind: "content" as const,
context: text.slice(Math.max(0, span.start - 70), Math.min(text.length, span.end + 70)),
}));
}
function documentHits(file: ProjectFile, hits: DocumentHit[]): Hit[] {
return hits.map((hit) => ({ ...hit, id: `${file.relPath}:document:${hit.id}`,
file: file.relPath, kind: "content" as const }));
}
function pathSegments(files: ProjectFile[]): PathDraft[] {
const map = new Map<string, PathDraft>();
for (const file of files) {
const parts = file.relPath.split("/");
for (let index = 0; index < parts.length; index++) {
const key = parts.slice(0, index + 1).join("/");
const isFile = index === parts.length - 1;
const value = isFile ? basename(parts[index], extname(parts[index])) : parts[index];
if (value && !map.has(key)) map.set(key, { key, value, hits: [] });
}
}
return [...map.values()];
}
async function prepare(root: string, files: ProjectFile[], rules: ProjectRule[], whitelist: ProjectWhitelistEntry[], entityTypes: EntityType[],
skipped: number, onProgress: (message: string) => void): Promise<Preview> {
await requireProjectCapabilities();
const masks = forceMasks(withoutWhitelistedRules(rules, whitelist));
const latestEntry = (await listRuns(root))[0];
const latest = latestEntry?.run;
const previousMapping: PlaceholderMapping = latest?.mapping ?? {};
const previousPlaceholders = Object.keys(previousMapping);
let mapping: PlaceholderMapping = previousMapping;
const drafts: Draft[] = [];
const retained: Preview["retained"] = [];
const draftsByPath = new Map<string, Draft>();
const hits: Hit[] = [];
const texts = new Map<string, string>();
const sourceInPlace = Boolean(latest?.inPlace || latest?.sourceInPlace);
// A migrated snapshot may differ from the source folder. Recheck its files
// unless an earlier run actually replaced them in place.
const previousOutputs = new Set(sourceInPlace ? latest?.files.map((file) => file.output) : []);
const previousDirectory = latestEntry?.directory;
for (const file of files) {
const data = await fs.readFile(file.absPath);
const hash = digest(data);
const recheckWordReviews = entityTypes.includes("PERSON") && file.kind === "document"
&& extname(file.relPath).toLowerCase() === ".docx"
&& (!latest?.commentAuthorCoverage || !latest?.reviewAuthorCoverage);
if (!recheckWordReviews && previousDirectory && previousOutputs.has(file.relPath)
&& await matchesPreviousOutput(previousDirectory, file.relPath, hash)) {
retained.push({ file, hash });
continue;
}
const draft: Draft = { file, hash, hits: [] };
drafts.push(draft);
draftsByPath.set(file.relPath, draft);
if (file.kind === "text") texts.set(file.relPath, textFromBytes(data));
}
const addHits = (draft: Draft, found: Hit[]) => {
draft.hits = withoutWhitelistedHits(withoutPlaceholderHits(found, previousPlaceholders), whitelist);
hits.push(...draft.hits);
if (hits.length > MAX_REVIEW_HITS) throw new Error("Zu viele Treffer. Bitte weniger Kategorien auswählen.");
};
const batches = makeTextBatches([...texts].map(([key, text]) => ({ key, text })));
for (const [index, batch] of batches.entries()) {
onProgress(`Textdateien analysieren: Paket ${index + 1}/${batches.length}`);
const result = await pseudonymize(batch.text, mapping, entityTypes, masks);
mapping = result.mapping;
const byFile = spansBySegment(batch, result.spans);
for (const segment of batch.segments) {
const draft = draftsByPath.get(segment.key)!;
addHits(draft, contentHits(draft.file, byFile.get(segment.key) ?? [], segment.text));
}
}
const documents = drafts.filter((draft) => draft.file.kind === "document");
for (const [index, draft] of documents.entries()) {
onProgress(`Dokumente analysieren: ${index + 1}/${documents.length}`);
const data = await fs.readFile(draft.file.absPath);
const result = await pseudonymizeDocument(basename(draft.file.relPath), data, mapping, masks, undefined, entityTypes);
mapping = result.mapping;
addHits(draft, documentHits(draft.file, result.hits));
}
// 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}`);
const result = await pseudonymize(batch.text, mapping, entityTypes, masks);
mapping = result.mapping;
const spans = spansBySegment(batch, result.spans);
for (const segment of batch.segments) {
const path = byPath.get(segment.key)!;
const found = 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,
}));
path.hits = withoutWhitelistedHits(withoutPlaceholderHits(found, previousPlaceholders), whitelist);
hits.push(...path.hits);
if (hits.length > MAX_REVIEW_HITS) throw new Error("Zu viele Treffer. Bitte weniger Kategorien auswählen.");
}
}
return { drafts, retained, paths, mapping, previousMapping,
matchModes: latest?.matchModes, hits, entityTypes, skipped, sourceInPlace };
}
function outputPath(relPath: string, paths: PathDraft[], accepted: Set<string>, document: boolean,
merges: Record<string, string>): string {
const lookup = new Map(paths.map((item) => [item.key, item]));
const parts = relPath.split("/");
const output = parts.map((part, index) => {
const key = parts.slice(0, index + 1).join("/");
const path = lookup.get(key);
if (!path) return part;
const isFile = index === parts.length - 1;
const extension = isFile ? (document && extname(part).toLowerCase() === ".pdf" ? ".docx" : extname(part)) : "";
return replaceSpans(path.value, withPersonMerges(path.hits, merges), accepted) + extension;
});
return safeRelativePath(output.join("/"));
}
function withPersonMerges(hits: Hit[], merges: Record<string, string>): Hit[] {
return hits.map((hit) => hit.type === "PERSON"
? { ...hit, placeholder: canonicalPerson(hit.placeholder, merges) } : hit);
}
function OccurrenceReview(props: { original: string; placeholder: string; hits: Hit[] }) {
return <List isShowingDetail navigationTitle={`${props.original} → ${props.placeholder}`}>
{props.hits.map((hit) => <List.Item key={hit.id} title={hit.source}
subtitle={hit.kind === "path" ? "Datei- oder Ordnername" : "Dateiinhalt"}
detail={<List.Item.Detail markdown={hit.context.replace(/</g, "&lt;")} />} />)}
</List>;
}
function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: ProjectWhitelistEntry[];
preview: Preview; onWhitelistChanged: (entries: ProjectWhitelistEntry[]) => void }) {
const groups = useMemo(() => groupReviewHits(props.preview.hits), [props.preview.hits]);
const [selectedGroups, setSelectedGroups] = useState<Set<string>>(() => new Set(groups.map((group) => group.key)));
const [stage, setStage] = useState<"selection" | "merge">("selection");
const [personMerges, setPersonMerges] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [completed, setCompleted] = useState<{ directory: string; inPlace: boolean; files: number } | null>(null);
const accepted = useMemo(() => new Set(groups.filter((group) => selectedGroups.has(group.key))
.flatMap((group) => group.hits.map((hit) => hit.id))), [groups, selectedGroups]);
const selectedOccurrences = accepted.size;
const byType = useMemo(() => {
const result = new Map<string, typeof groups>();
for (const group of groups) (result.get(group.type) ?? (result.set(group.type, []), result.get(group.type)!)).push(group);
return result;
}, [groups]);
const people = useMemo(() => {
const result = new Map<string, string[]>();
for (const group of groups) {
if (group.type !== "PERSON" || !selectedGroups.has(group.key)) continue;
const originals = result.get(group.placeholder) ?? [];
if (!originals.includes(group.original)) originals.push(group.original);
result.set(group.placeholder, originals);
}
return [...result].map(([placeholder, originals]) => ({ placeholder, originals }));
}, [groups, selectedGroups]);
const earlierPeople = useMemo(() => Object.entries(props.preview.previousMapping)
.filter(([placeholder, entry]) => entry.type === "PERSON" && !entry.merged_into
&& !people.some((person) => person.placeholder === placeholder))
.map(([placeholder, entry]) => ({ placeholder, originals: [entry.original] })),
[people, props.preview.previousMapping]);
function toggle(key: string) {
setSelectedGroups((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
async function apply(inPlace = true) {
if (!props.preview.drafts.length && !accepted.size) {
try {
const rejected = groups.map((group) => ({ type: group.type, original: group.original }));
if (rejected.length) {
await saveProjectWhitelist(props.root, [...await loadProjectWhitelist(props.root), ...rejected]);
props.onWhitelistChanged(await loadProjectWhitelist(props.root));
}
await showToast({ style: Toast.Style.Success, title: "Keine neuen Änderungen",
message: rejected.length
? "Der bestehende Projektstand bleibt erhalten. Abgewählte Begriffe wurden als Ausnahmen gespeichert."
: "Der bestehende Projektstand bleibt erhalten." });
} catch (error) {
await showToast({ style: Toast.Style.Failure, title: "Ausnahmen konnten nicht gespeichert werden",
message: (error as Error).message });
}
return;
}
if (inPlace && !(await confirmAlert({
title: "Originaldateien ersetzen?",
message: "Die gewählten Dateien und gegebenenfalls ihre Namen werden im Projektordner ersetzt. Die Originale werden vorher außerhalb des Projekts gesichert. PDF-Dateien werden als DOCX ausgegeben.",
primaryAction: { title: "Originaldateien ersetzen", style: Alert.ActionStyle.Destructive },
}))) return;
setBusy(true);
const toast = await showToast({ style: Toast.Style.Animated,
title: inPlace ? "Originaldateien werden ersetzt" : "Pseudonymisierte Kopien werden erstellt" });
let directory: string | undefined;
let publishedCopyPath: string | undefined;
let persisted = false;
try {
const created = await createRun(props.root);
directory = created.directory;
const { run } = created;
const acceptedHits = groups.filter((group) => selectedGroups.has(group.key))
.flatMap((group) => group.hits);
const selectedPeople = new Set(people.map((person) => person.placeholder));
const availableTargets = new Set([...selectedPeople, ...earlierPeople.map((person) => person.placeholder)]);
const activeMerges = Object.fromEntries(Object.entries(personMerges).filter(([source, target]) =>
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.reviewAuthorCoverage = props.preview.entityTypes.includes("PERSON");
run.matchModes = props.preview.matchModes;
run.sourceInPlace = props.preview.sourceInPlace || inPlace;
const usedPaths = new Set<string>();
const replacements: RunFile[] = [];
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 = 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 });
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);
if (digest(data) !== draft.hash) throw new Error(`Datei wurde seit der Vorschau geändert: ${draft.file.relPath}`);
const relPath = outputPath(draft.file.relPath, props.preview.paths, accepted,
draft.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 });
let output: Buffer;
if (draft.file.kind === "document") {
const docIds = draft.hits.filter((hit) => accepted.has(hit.id)).map((hit) => hit.id.split(":document:")[1]);
const result = await pseudonymizeDocument(
basename(draft.file.relPath), data, run.mapping,
forceMasks(withoutWhitelistedRules(props.rules, props.whitelist)), docIds, props.preview.entityTypes,
);
output = Buffer.from(result.file_base64, "base64");
} else {
output = Buffer.from(replaceSpans(textFromBytes(data), withPersonMerges(draft.hits, activeMerges), accepted), "utf8");
}
await fs.writeFile(target, output, { mode: 0o600 });
const file: RunFile = { source: draft.file.relPath, output: relPath, kind: draft.file.kind };
run.files.push(file);
replacements.push(file);
}
if (!inPlace) {
publishedCopyPath = await publishProjectCopies(run, directory);
run.copyPath = publishedCopyPath;
}
await saveRun(directory, run);
persisted = true;
if (inPlace) {
await replaceProjectFilesInPlace(props.root, directory, replacements);
run.inPlace = true;
await saveRun(directory, run);
}
const selectedTerms = new Set(groups.filter((group) => selectedGroups.has(group.key)).map(whitelistKey));
const rejected = groups.filter((group) => !selectedGroups.has(group.key)
&& !selectedTerms.has(whitelistKey(group)))
.map((group) => ({ type: group.type, original: group.original }));
if (rejected.length) {
const nextWhitelist = [...await loadProjectWhitelist(props.root), ...rejected];
await saveProjectWhitelist(props.root, nextWhitelist);
props.onWhitelistChanged(await loadProjectWhitelist(props.root));
}
toast.style = Toast.Style.Success;
toast.title = inPlace ? `${replacements.length} Dateien ersetzt` : `${run.files.length} Kopien erstellt`;
toast.message = inPlace ? "Die Sicherung ist im Raycast-Datenordner gespeichert."
: "Die Kopien liegen neben dem Projektordner.";
setCompleted({ directory: inPlace ? join(directory, "backup") : publishedCopyPath!, inPlace, files: run.files.length });
await showInFinder(inPlace ? props.root : publishedCopyPath!).catch(() => undefined);
} catch (error) {
if (directory && !persisted) await fs.rm(directory, { recursive: true, force: true });
if (publishedCopyPath && !persisted) await fs.rm(publishedCopyPath, { recursive: true, force: true });
toast.style = Toast.Style.Failure;
toast.title = "Pseudonymisierung fehlgeschlagen";
toast.message = `${(error as Error).message}${persisted && directory ? ` Kopien/Sicherung: ${directory}` : ""}`;
} finally { setBusy(false); }
}
if (completed) return <List navigationTitle="Pseudonymisierung abgeschlossen">
<List.Item icon={Icon.CheckCircle}
title={completed.inPlace ? "Projektdateien ersetzt" : `${completed.files} pseudonymisierte Kopien erstellt`}
subtitle={completed.inPlace ? "Die Originale wurden gesichert."
: completed.directory}
actions={<ActionPanel>
<Action title={completed.inPlace ? "Sicherung im Finder anzeigen" : "Kopien im Finder anzeigen"}
icon={Icon.Folder} onAction={() => showInFinder(completed.directory)} />
<Action.CopyToClipboard title="Speicherpfad kopieren" content={completed.directory} />
</ActionPanel>} />
</List>;
if (stage === "merge") return <List isLoading={busy} navigationTitle="Personen zusammenfassen"
searchBarPlaceholder="Person oder Platzhalter suchen">
<List.Section title="Abschluss">
<List.Item icon={Icon.Play} title="Pseudonymisierung starten"
subtitle={`${selectedGroups.size} Zuordnungen · ${people.length} Personen · ${props.preview.retained.length} unveränderte Dateien übernommen`}
actions={<ActionPanel>
<Action title="Originaldateien ersetzen" icon={Icon.Pencil} onAction={() => apply()} />
<Action title="Pseudonymisierte Kopien erstellen" icon={Icon.Document} onAction={() => apply(false)} />
<Action title="Zur Trefferauswahl" icon={Icon.ArrowLeft} onAction={() => setStage("selection")} />
</ActionPanel>} />
</List.Section>
<List.Section title="Ausgewählte Personen · optional zusammenfassen">
{people.map((person) => {
const canonical = canonicalPerson(person.placeholder, personMerges);
const targets = [...people, ...earlierPeople]
.filter((other) => canonicalPerson(other.placeholder, personMerges) !== canonical);
return <List.Item key={person.placeholder} icon={Icon.Person}
title={person.originals.join(" · ")}
subtitle={`→ ${canonical}`}
accessories={canonical !== person.placeholder ? [{ text: "Zusammengeführt" }] : []}
actions={<ActionPanel>
{targets.length > 0 && <ActionPanel.Submenu title="Mit Person zusammenfassen" icon={Icon.Link}>
{targets.map((target) => <Action key={target.placeholder}
title={`${target.originals.join(" · ")} → ${canonicalPerson(target.placeholder, personMerges)}`}
onAction={() => setPersonMerges((current) => mergePeople(current,
person.placeholder, target.placeholder))} />)}
</ActionPanel.Submenu>}
{canonical !== person.placeholder && <Action title="Zusammenführung lösen"
onAction={() => setPersonMerges((current) => {
const next = { ...current }; delete next[person.placeholder]; return next;
})} />}
<Action title="Originaldateien ersetzen" icon={Icon.Pencil} onAction={() => apply()} />
<Action title="Pseudonymisierte Kopien erstellen" icon={Icon.Document} onAction={() => apply(false)} />
</ActionPanel>} />;
})}
</List.Section>
</List>;
return <List isLoading={busy} navigationTitle="Zuordnungen prüfen"
searchBarPlaceholder="Begriff oder Platzhalter suchen">
<List.Section title="Abschluss">
<List.Item key="apply" icon={Icon.Play}
title={`${selectedGroups.size} von ${groups.length} Zuordnungen ausgewählt`}
subtitle={`${selectedOccurrences} Vorkommen · ${props.preview.drafts.length} neue/geänderte Dateien · ${props.preview.retained.length} unverändert · ${props.preview.skipped} übersprungen`}
actions={<ActionPanel>
<Action title="Auswahl abschließen und Personen prüfen" icon={Icon.ArrowRight}
onAction={() => setStage("merge")} />
</ActionPanel>} />
</List.Section>
{[...byType].map(([type, entries]) => <List.Section key={type}
title={`${labelForType(type)} · ${entries.filter((entry) => selectedGroups.has(entry.key)).length}/${entries.length}`}>
{entries.map((group) => <List.Item key={group.key}
icon={{ source: selectedGroups.has(group.key) ? Icon.CheckCircle : Icon.Circle,
tintColor: selectedGroups.has(group.key) ? Color.Green : Color.SecondaryText }}
title={group.original} subtitle={`→ ${group.placeholder}`}
accessories={[{ text: `${group.hits.length} Vorkommen` },
...(group.hits.some((hit) => hit.source === "Kommentarautor") ? [{ text: "Word-Kommentator" }] : []),
...(group.hits.some((hit) => hit.source === "Änderungsautor") ? [{ text: "Word-Bearbeiter" }] : [])]}
keywords={[type, labelForType(type)]}
actions={<ActionPanel>
<Action title={selectedGroups.has(group.key) ? "Zuordnung abwählen" : "Zuordnung auswählen"}
onAction={() => toggle(group.key)} />
<Action title={`${labelForType(type)} komplett abwählen`}
onAction={() => setSelectedGroups((current) => {
const next = new Set(current);
for (const entry of entries) next.delete(entry.key);
return next;
})} />
<Action title={`${labelForType(type)} komplett auswählen`}
onAction={() => setSelectedGroups((current) => new Set([...current, ...entries.map((entry) => entry.key)]))} />
<Action.Push title="Vorkommen ansehen" icon={Icon.Eye}
target={<OccurrenceReview original={group.original} placeholder={group.placeholder} hits={group.hits} />} />
<Action title="Auswahl abschließen und Personen prüfen" icon={Icon.ArrowRight}
onAction={() => setStage("merge")} />
</ActionPanel>} />)}
</List.Section>)}
</List>;
}
function ProjectWhitelist(props: { entries: ProjectWhitelistEntry[]; onRemove: (entry: ProjectWhitelistEntry) => Promise<void> }) {
return <List navigationTitle="Projekt-Whitelist" searchBarPlaceholder="Ausnahme suchen">
<List.Section title={`${props.entries.length} nicht mehr vorgeschlagene Begriffe`}>
{props.entries.map((entry) => <List.Item key={whitelistKey(entry)} title={entry.original}
subtitle={labelForType(entry.type)} actions={<ActionPanel>
<Action title="Aus Projekt-Whitelist entfernen" icon={Icon.Trash}
onAction={() => props.onRemove(entry)} />
</ActionPanel>} />)}
</List.Section>
</List>;
}
export default function Command() {
const { push } = useNavigation();
const [root, setRoot] = useState("");
const [rules, setRules] = useState("");
const [availableTypes, setAvailableTypes] = useState<EntityType[]>([]);
const [selectedTypes, setSelectedTypes] = useState<EntityType[]>([]);
const [typesLoaded, setTypesLoaded] = useState(false);
const [typesReadyFor, setTypesReadyFor] = useState<string | null>(null);
const [whitelist, setWhitelist] = useState<ProjectWhitelistEntry[]>([]);
const [existingRuns, setExistingRuns] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => { finderFolder().then((path) => { if (path) setRoot((current) => current || path); }); }, []);
useEffect(() => {
getEntityTypes().then((types) => {
setAvailableTypes(types);
setTypesLoaded(true);
}).catch((error: Error) => showToast({ style: Toast.Style.Failure,
title: "Kategorien nicht verfügbar", message: error.message }));
}, []);
useEffect(() => {
if (!typesLoaded) return;
const defaults = availableTypes.filter((type) => type !== "ORT");
if (!root) { setSelectedTypes(defaults); setTypesReadyFor(""); return; }
let active = true;
setTypesReadyFor(null);
loadProjectEntityTypes(root).then((stored) => {
if (!active) return;
setSelectedTypes(stored === null ? defaults : stored.filter((type) => availableTypes.includes(type)));
setTypesReadyFor(root);
}).catch((error: Error) => showToast({ style: Toast.Style.Failure,
title: "Projektkategorien nicht lesbar", message: error.message }));
return () => { active = false; };
}, [root, availableTypes, typesLoaded]);
useEffect(() => {
if (!root) return;
let active = true;
loadProjectRules(root).then((found) => {
if (active) setRules(found.map((rule) => `${rule.type}: ${rule.value}`).join("\n"));
}).catch(() => { if (active) setRules(""); });
return () => { active = false; };
}, [root]);
useEffect(() => {
if (!root) { setWhitelist([]); return; }
let active = true;
loadProjectWhitelist(root).then((found) => { if (active) setWhitelist(found); })
.catch((error: Error) => showToast({ style: Toast.Style.Failure,
title: "Projekt-Whitelist nicht lesbar", message: error.message }));
return () => { active = false; };
}, [root]);
useEffect(() => {
if (!root) { setExistingRuns(null); return; }
setExistingRuns(null);
let active = true;
listRuns(root).then((found) => { if (active) setExistingRuns(found.length); })
.catch((error: Error) => showToast({ style: Toast.Style.Failure,
title: "Projektstatus nicht lesbar", message: error.message }));
return () => { active = false; };
}, [root]);
async function removeFromWhitelist(entry: ProjectWhitelistEntry) {
try {
const next = (await loadProjectWhitelist(root)).filter((item) => whitelistKey(item) !== whitelistKey(entry));
await saveProjectWhitelist(root, next);
setWhitelist(next);
await showToast({ style: Toast.Style.Success, title: "Begriff wird wieder vorgeschlagen" });
} catch (error) {
await showToast({ style: Toast.Style.Failure, title: "Entfernen fehlgeschlagen",
message: (error as Error).message });
}
}
async function next() {
setBusy(true);
const toast = await showToast({ style: Toast.Style.Animated, title: "Projekt wird vorbereitet" });
try {
if (!(await fs.stat(root)).isDirectory()) throw new Error("Bitte einen Projektordner wählen.");
if (typesReadyFor !== root) throw new Error("Projektkategorien werden noch geladen oder sind nicht lesbar.");
if (!selectedTypes.length) throw new Error("Bitte mindestens eine Kategorie wählen.");
const parsed = parseProjectRules(rules);
await saveProjectEntityTypes(root, selectedTypes);
await saveProjectRules(root, parsed);
toast.title = "Dateien werden erfasst";
const scan = await scanProjectFiles(root);
if (!scan.files.length) throw new Error("Keine geeigneten Dateien im Projektordner gefunden.");
const currentWhitelist = await loadProjectWhitelist(root);
const preview = await prepare(root, scan.files, parsed, currentWhitelist, selectedTypes, scan.skipped.length,
(message) => { toast.title = message; });
toast.style = Toast.Style.Success;
toast.title = `${groupReviewHits(preview.hits).length} Zuordnungen zur Prüfung`;
push(<HitReview root={root} rules={parsed} whitelist={currentWhitelist} preview={preview}
onWhitelistChanged={setWhitelist} />);
} catch (error) {
toast.style = Toast.Style.Failure;
toast.title = "Analyse fehlgeschlagen";
toast.message = (error as Error).message;
} finally { setBusy(false); }
}
async function useFinderFolder() {
const path = await finderFolder();
if (path) setRoot(path);
else await showToast({ style: Toast.Style.Failure, title: "Kein Finder-Ordner gefunden",
message: "Bitte den Ordner im Finder öffnen oder unten manuell auswählen." });
}
return <Form isLoading={busy} actions={<ActionPanel>
<Action title={existingRuns ? "Bestehendes Projekt erneut prüfen" : "Zuordnungen prüfen"} onAction={next} />
<Action title="Geöffneten Finder-Ordner übernehmen" icon={Icon.Folder} onAction={useFinderFolder} />
<Action.Push title="Projekt-Whitelist bearbeiten" icon={Icon.List}
target={<ProjectWhitelist entries={whitelist} onRemove={removeFromWhitelist} />} />
</ActionPanel>}>
<Form.FilePicker id="projectPath" title="Projektordner" allowMultipleSelection={false}
canChooseDirectories canChooseFiles={false} value={root ? [root] : []}
onChange={(paths) => setRoot(paths[0] ?? "")} />
<Form.Description title="Projektstatus" text={existingRuns === null ? "Projektordner auswählen."
: existingRuns ? `Bereits angelegt · ${existingRuns} gespeicherte Stände. Ein erneuter Lauf aktualisiert dieses Projekt; es wird kein zweites angelegt.`
: "Neues Projekt. Der erste Stand wird nach der Prüfung angelegt."} />
<Form.TextArea id="projectRules" title="Begriffe nur für dieses Projekt" value={rules}
onChange={setRules} placeholder={"BEGRIFF: KundenSuite\nPERSON: Max Mustermann"}
info="Ein Begriff pro Zeile. Ohne Typ wird BEGRIFF verwendet. Diese Regeln werden nur für dieses Projekt gespeichert und bei der Velum-Anfrage mitgesendet." />
<Form.TagPicker id="entityTypes" title="Kategorien pseudonymisieren" value={selectedTypes}
onChange={setSelectedTypes} info="Die Auswahl wird beim Prüfen für diesen Projektordner gespeichert. Word-Kommentatoren und Bearbeiter von Änderungen werden mit der Kategorie Personen maskiert. Bei neuen Projekten sind Orte zunächst abgewählt.">
{availableTypes.map((type) => <Form.TagPicker.Item key={type} value={type} title={labelForType(type)} />)}
</Form.TagPicker>
<Form.Description title="Projekt-Whitelist" text={`${whitelist.length} Begriffe werden bei diesem Ordner nicht mehr vorgeschlagen. Über die Aktionen kannst du sie wieder freigeben.`} />
<Form.Description title="Ablauf" text="Kategorien wählen, Zuordnungen auswählen und Personen bei Bedarf zusammenfassen. Standardmäßig werden die Originaldateien nach Bestätigung ersetzt; Kopien sind als Alternative verfügbar." />
</Form>;
}

View File

@@ -0,0 +1,316 @@
import { Action, ActionPanel, Alert, confirmAlert, Icon, List, showInFinder, showToast, Toast, useNavigation } from "@raycast/api";
import { promises as fs } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join } from "node:path";
import { useEffect, useState } from "react";
import { depseudonymizeDocument } from "./velum";
import { canonicalProjectPath, listAllRuns, resolveWithin, restorePath, restoreTextWithCount, runCopiesPath, scanRestorableCopyFiles } from "./lib/velum-project";
import type { ProjectFile, ProjectRun } from "./lib/velum-project";
import { canonicalPerson, mergePeople } from "./lib/project-review";
import { planProjectRevision, reviseProjectRun } from "./lib/project-revision";
import { TranslationTable } from "./show-translation-tables";
type SavedRun = { run: ProjectRun; directory: string };
function RevisionReview(props: { saved: SavedRun; onSaved: () => Promise<void> }) {
const { pop } = useNavigation();
const { run, directory } = props.saved;
const [release, setRelease] = useState<Set<string>>(new Set());
const [merges, setMerges] = useState<Record<string, string>>({});
const [stage, setStage] = useState<"release" | "merge">("release");
const [busy, setBusy] = useState(false);
const entries = Object.entries(run.mapping);
const rootOf = (key: string): string => {
let current = key;
const seen = new Set<string>();
while (run.mapping[current]?.merged_into && !seen.has(current)) {
seen.add(current);
current = run.mapping[current].merged_into!;
}
return current;
};
const roots = entries.filter(([key]) => rootOf(key) === key);
const byType = new Map<string, typeof roots>();
for (const item of roots) {
const type = item[1].type;
if (!byType.has(type)) byType.set(type, []);
byType.get(type)!.push(item);
}
const people = roots.filter(([key, entry]) => entry.type === "PERSON" && !release.has(key));
const variants = (key: string) => entries.filter(([candidate]) => rootOf(candidate) === key)
.map(([, entry]) => entry.original);
function toggle(key: string) {
setRelease((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
setMerges((current) => Object.fromEntries(Object.entries(current)
.filter(([source, target]) => source !== key && target !== key)));
}
async function apply() {
const plan = planProjectRevision(run.mapping, release, merges);
if (!plan.released.length && !plan.merged.length) {
await showToast({ style: Toast.Style.Failure, title: "Keine Änderung ausgewählt" });
return;
}
const inPlace = Boolean(run.inPlace || run.sourceInPlace);
if (inPlace && !(await confirmAlert({
title: "Projektdateien aktualisieren?",
message: `Der vorhandene Projektordner wird mit dem neuen Stand aktualisiert. ${plan.released.length} Zuordnungen werden freigegeben. Vorher wird außerhalb des Projektordners eine Sicherung erstellt. Zwischenzeitlich geänderte Dateien werden nicht überschrieben.`,
primaryAction: { title: "Projekt aktualisieren", style: Alert.ActionStyle.Destructive },
}))) return;
setBusy(true);
const toast = await showToast({ style: Toast.Style.Animated, title: "Neuer Projektstand wird erstellt" });
try {
const result = await reviseProjectRun({ previous: run, previousDirectory: directory,
release, merges, rewriteDocument: depseudonymizeDocument });
await props.onSaved();
toast.style = Toast.Style.Success;
toast.title = "Projektstand aktualisiert";
toast.message = `${plan.released.length} freigegeben · ${plan.merged.length} zusammengeführt`;
pop();
await showInFinder(inPlace ? run.projectPath : runCopiesPath(result.run, result.directory)).catch(() => undefined);
} catch (error) {
toast.style = Toast.Style.Failure;
toast.title = "Nachbearbeitung fehlgeschlagen";
toast.message = (error as Error).message;
} finally { setBusy(false); }
}
if (stage === "release") return <List isLoading={busy} navigationTitle="Zuordnungen nachbearbeiten"
searchBarPlaceholder="Begriff, Typ oder Platzhalter suchen">
<List.Section title="Weiter">
<List.Item icon={Icon.ArrowRight} title="Personen zusammenführen und Änderungen anwenden"
subtitle={`${release.size} Begriffe oder Personengruppen freigeben`}
actions={<ActionPanel><Action title="Personen prüfen" onAction={() => setStage("merge")} /></ActionPanel>} />
</List.Section>
{[...byType].map(([type, items]) => <List.Section key={type}
title={`${type} · ${items.filter(([key]) => release.has(key)).length}/${items.length} freigeben`}>
{items.map(([key, entry]) => <List.Item key={key}
icon={release.has(key) ? Icon.CheckCircle : Icon.Circle}
title={entry.original} subtitle={`→ ${key}`}
accessories={[{ text: entry.type }, ...(entry.type === "PERSON" && variants(key).length > 1
? [{ text: `${variants(key).length} Varianten` }] : [])]}
keywords={[key, entry.type, ...variants(key)]}
actions={<ActionPanel>
<Action title={release.has(key) ? "Freigabe zurücknehmen" : "In Klartext umwandeln und ausnehmen"}
onAction={() => toggle(key)} />
<Action title="Personen prüfen" onAction={() => setStage("merge")} />
</ActionPanel>} />)}
</List.Section>)}
</List>;
return <List isLoading={busy} navigationTitle="Personen nachträglich zusammenführen"
searchBarPlaceholder="Person oder Platzhalter suchen">
<List.Section title="Abschluss">
<List.Item icon={Icon.Play} title="Neuen Projektstand erstellen"
subtitle={`${release.size} Freigaben · ${Object.keys(merges).length} Zusammenführungen`}
actions={<ActionPanel>
<Action title="Änderungen anwenden" onAction={apply} />
<Action title="Zur Freigabe zurück" onAction={() => setStage("release")} />
</ActionPanel>} />
</List.Section>
<List.Section title="Personen · optional zusammenfassen">
{people.map(([key]) => {
const target = canonicalPerson(key, merges);
const targets = people.filter(([other]) => canonicalPerson(other, merges) !== target);
return <List.Item key={key} icon={Icon.Person} title={variants(key).join(" · ")}
subtitle={`→ ${target}`}
accessories={target !== key ? [{ text: "Zusammengeführt" }] : []}
actions={<ActionPanel>
{targets.length > 0 && <ActionPanel.Submenu title="Mit Person zusammenfassen" icon={Icon.Link}>
{targets.map(([other]) => <Action key={other}
title={`${variants(other).join(" · ")} → ${canonicalPerson(other, merges)}`}
onAction={() => setMerges((current) => mergePeople(current, key, other))} />)}
</ActionPanel.Submenu>}
{target !== key && <Action title="Zusammenführung lösen" onAction={() => setMerges((current) => {
const next = { ...current }; delete next[key]; return next;
})} />}
<Action title="Änderungen anwenden" onAction={apply} />
</ActionPanel>} />;
})}
</List.Section>
</List>;
}
function FilePicker(props: { run: ProjectRun; directory: string; historical?: boolean }) {
const [files, setFiles] = useState<ProjectFile[]>([]);
const [sourceRoot, setSourceRoot] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [skipped, setSkipped] = useState(0);
const [busy, setBusy] = useState(false);
useEffect(() => {
let active = true;
async function load() {
const scan = await scanRestorableCopyFiles(props.run, props.directory, !props.historical);
if (!active) return;
setSourceRoot(scan.root);
setFiles(scan.files);
setSelected(new Set(scan.files.filter((file) => file.kind === "text").map((file) => file.relPath)));
setSkipped(scan.skipped);
}
load().catch((error: Error) => showToast({ style: Toast.Style.Failure,
title: "Kopienordner nicht lesbar", message: error.message }));
return () => { active = false; };
}, [props.run, props.directory, props.historical]);
function toggle(path: string) {
setSelected((current) => {
const next = new Set(current);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
}
async function exportFiles() {
if (busy) return;
if (!selected.size) {
await showToast({ style: Toast.Style.Failure, title: "Keine Dateien ausgewählt" });
return;
}
if (!sourceRoot) return;
setBusy(true);
const toast = await showToast({ style: Toast.Style.Animated, title: "Klartext-Kopien werden erstellt" });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const parent = join(homedir(), "Downloads", "Velum");
let targetRoot: string | undefined;
try {
if (!Object.keys(props.run.mapping).length) throw new Error("Dieser Projektstand hat keine Übersetzungstabelle.");
const names = new Set<string>();
const planned = files.filter((item) => selected.has(item.relPath)).map((file) => ({
file, restoredName: restorePath(file.relPath, props.run.mapping, props.run.matchModes),
}));
const renamedFiles = planned.filter(({ file, restoredName }) => restoredName !== file.relPath).length;
let contentReplacements = 0;
for (const { restoredName } of planned) {
if (names.has(restoredName)) throw new Error(`Mehrdeutiger Ausgabename: ${restoredName}`);
names.add(restoredName);
}
await fs.mkdir(parent, { recursive: true, mode: 0o700 });
targetRoot = await fs.mkdtemp(join(parent, `${basename(props.run.projectName)}-${stamp}-`));
for (const { file, restoredName } of planned) {
const source = resolveWithin(sourceRoot, file.relPath);
const target = resolveWithin(targetRoot, restoredName);
if (!(await fs.lstat(source)).isFile()) throw new Error(`Keine reguläre Datei: ${file.relPath}`);
const data = await fs.readFile(source);
let result: Buffer;
if (file.kind === "document") {
result = await depseudonymizeDocument(basename(file.relPath), data, props.run.mapping);
} else {
const restored = restoreTextWithCount(data.toString("utf8"), props.run.mapping, props.run.matchModes);
contentReplacements += restored.replacements;
result = Buffer.from(restored.text, "utf8");
}
await fs.mkdir(dirname(target), { recursive: true, mode: 0o700 });
await fs.writeFile(target, result, { flag: "wx", mode: 0o600 });
}
if (!renamedFiles && !contentReplacements && planned.every(({ file }) => file.kind === "text")) {
throw new Error("Keine passenden Velum-Platzhalter in den ausgewählten Textdateien oder Dateinamen gefunden. Bitte Quelle und Projektstand prüfen.");
}
toast.style = Toast.Style.Success;
toast.title = `${selected.size} Klartext-Dateien in Downloads`;
toast.message = `${contentReplacements} Textersetzungen · ${renamedFiles} Dateinamen geändert`
+ (planned.some(({ file }) => file.kind === "document") ? " · Office-Inhalte nicht gezählt" : "");
await showInFinder(targetRoot).catch(() => undefined);
} catch (error) {
if (targetRoot) await fs.rm(targetRoot, { recursive: true, force: true });
toast.style = Toast.Style.Failure;
toast.title = "Klartext-Export fehlgeschlagen";
toast.message = (error as Error).message;
} finally { setBusy(false); }
}
const textFiles = files.filter((file) => file.kind === "text");
const complexFiles = files.filter((file) => file.kind === "document");
const sourceLabel = props.historical ? "Gespeicherter Projektstand"
: props.run.inPlace || props.run.sourceInPlace ? "Aktueller Projektordner" : "Pseudonymisierte Kopien";
const renderFile = (file: ProjectFile) => <List.Item key={file.relPath}
icon={selected.has(file.relPath) ? Icon.CheckCircle : Icon.Circle}
title={restorePath(file.relPath, props.run.mapping, props.run.matchModes)} subtitle={file.relPath}
accessories={file.kind === "document" ? [{ text: "Office · manuell auswählen" }] : []}
actions={<ActionPanel>
<Action title={selected.has(file.relPath) ? "Datei abwählen" : "Datei auswählen"} onAction={() => toggle(file.relPath)} />
<Action title="Auswahl nach Downloads wiederherstellen" icon={Icon.Download} onAction={exportFiles} />
<Action title="Nur Textdateien auswählen" onAction={() => setSelected(new Set(textFiles.map((item) => item.relPath)))} />
<Action title="Alle Dateien auswählen (inklusive Office)" onAction={() => setSelected(new Set(files.map((item) => item.relPath)))} />
<Action title="Keine auswählen" onAction={() => setSelected(new Set())} />
</ActionPanel>} />;
return <List isLoading={busy || !sourceRoot} navigationTitle={`${props.run.projectName}: Dateien für Klartext wählen`}
searchBarPlaceholder="Dateien filtern">
<List.Section title={`${selected.size} von ${files.length} Dateien ausgewählt · ${skipped} übersprungen`}>
<List.Item key="export" icon={Icon.Download} title="Ausgewählte Dateien wiederherstellen"
subtitle="Klartext-Kopien in Downloads/Velum erstellen · Office-Dateien nur nach Auswahl"
actions={<ActionPanel><Action title="Auswahl nach Downloads wiederherstellen"
icon={Icon.Download} onAction={exportFiles} /></ActionPanel>} />
<List.Item key="source" icon={Icon.Folder} title={sourceLabel} subtitle={sourceRoot}
actions={<ActionPanel><Action title="Quellordner im Finder anzeigen" onAction={() => showInFinder(sourceRoot)} /></ActionPanel>} />
</List.Section>
<List.Section title={`Textdateien · ${textFiles.length} · standardmäßig ausgewählt`}>
{textFiles.map(renderFile)}
</List.Section>
<List.Section title={`Office-Dateien · ${complexFiles.length} · bewusst auswählen`}>
{complexFiles.map(renderFile)}
</List.Section>
</List>;
}
function ProjectHistory(props: { projectName: string; entries: SavedRun[] }) {
const { push } = useNavigation();
return <List navigationTitle={`${props.projectName}: frühere Stände`}
searchBarPlaceholder="Stand suchen">
<List.Section title="Frühere Stände · nur für Klartext-Export">
{props.entries.map(({ run, directory }) => <List.Item key={run.id} icon={Icon.Clock}
title={new Date(run.createdAt).toLocaleString("de-AT")}
subtitle={`${run.files.length} Dateien`}
actions={<ActionPanel>
<Action title="Gespeicherten Stand wiederherstellen" icon={Icon.Download}
onAction={() => push(<FilePicker run={run} directory={directory} historical />)} />
<Action.Push title="Übersetzungstabelle anzeigen" icon={Icon.List}
target={<TranslationTable title={`${props.projectName} · ${new Date(run.createdAt).toLocaleString("de-AT")}`}
mapping={run.mapping} />} />
<Action title="Pseudonymisierte Kopien im Finder anzeigen" icon={Icon.Folder}
onAction={() => showInFinder(runCopiesPath(run, directory))} />
<Action.CopyToClipboard title="Speicherpfad kopieren" content={runCopiesPath(run, directory)} />
{run.copyPath && <Action title="Aktuelle Dateien dieses Kopienordners wiederherstellen" icon={Icon.Download}
onAction={() => push(<FilePicker run={run} directory={directory} />)} />}
</ActionPanel>} />)}
</List.Section>
</List>;
}
export default function Command() {
const { push } = useNavigation();
const [runs, setRuns] = useState<SavedRun[]>([]);
const [busy, setBusy] = useState(true);
useEffect(() => {
listAllRuns().then(setRuns).catch((error: Error) => showToast({ style: Toast.Style.Failure,
title: "Projektstände nicht lesbar", message: error.message })).finally(() => setBusy(false));
}, []);
async function refresh() { setRuns(await listAllRuns()); }
const projects = new Map<string, SavedRun[]>();
for (const entry of runs) {
const path = canonicalProjectPath(entry.run.projectPath);
if (!projects.has(path)) projects.set(path, []);
projects.get(path)!.push(entry);
}
return <List isLoading={busy} navigationTitle="Projekte nachbearbeiten oder rückübersetzen" searchBarPlaceholder="Projekt suchen">
<List.EmptyView title="Noch keine Projektstände" description="Zuerst Dateien mit Velum pseudonymisieren." />
{[...projects].map(([path, entries]) => {
const [{ run, directory }] = entries;
return <List.Item key={path} icon={Icon.Folder}
title={run.projectName} subtitle={path}
accessories={[{ text: `${run.files.length} Dateien` }, { text: new Date(run.createdAt).toLocaleString("de-AT") },
...(entries.length > 1 ? [{ text: `${entries.length} Stände` }] : [])]}
actions={<ActionPanel>
<Action title="Projektdateien als Klartext wiederherstellen" icon={Icon.Download}
onAction={() => push(<FilePicker run={run} directory={directory} />)} />
<Action.Push title="Übersetzungstabelle anzeigen" icon={Icon.List}
target={<TranslationTable title={run.projectName} mapping={run.mapping} />} />
<Action title="Pseudonymisierte Kopien im Finder anzeigen" icon={Icon.Folder}
onAction={() => showInFinder(runCopiesPath(run, directory))} />
<Action.CopyToClipboard title="Speicherpfad kopieren" content={runCopiesPath(run, directory)} />
<Action title="Aktuellen Stand nachbearbeiten" icon={Icon.Pencil}
onAction={() => push(<RevisionReview saved={{ run, directory }} onSaved={refresh} />)} />
{entries.length > 1 && <Action title="Frühere Stände anzeigen" icon={Icon.Clock}
onAction={() => push(<ProjectHistory projectName={run.projectName} entries={entries.slice(1)} />)} />}
</ActionPanel>} />;
})}
</List>;
}

27
src/rich-clipboard.ts Normal file
View File

@@ -0,0 +1,27 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
// Raycast 2.0 Beta writes only plain text for Clipboard.copy({ html, text }).
// Set both pasteboard types directly; environment variables avoid shell quoting.
const RICH_COPY_SCRIPT = [
"ObjC.import('AppKit');",
"const env = $.NSProcessInfo.processInfo.environment;",
"const html = env.objectForKey('VELUM_HTML');",
"const text = env.objectForKey('VELUM_TEXT');",
"const pb = $.NSPasteboard.generalPasteboard;",
"pb.clearContents;",
"pb.setStringForType(html, 'public.html');",
"pb.setStringForType(text, 'public.utf8-plain-text');",
].join("\n");
export async function copyRichText(html: string, text: string): Promise<void> {
await execFileAsync(
"osascript",
["-l", "JavaScript", "-e", RICH_COPY_SCRIPT],
{
env: { ...process.env, VELUM_HTML: html, VELUM_TEXT: text },
},
);
}

View File

@@ -0,0 +1,224 @@
import {
Action,
ActionPanel,
Detail,
Icon,
List,
showToast,
Toast,
} from "@raycast/api";
import { useEffect, useState } from "react";
import { canonicalProjectPath, listAllRuns } from "./lib/velum-project";
import { listSessions } from "./sessions";
import type { PlaceholderMapping } from "./types";
import { markdownCodeBlock } from "./ui";
type TableSource = {
id: string;
title: string;
subtitle: string;
mapping: PlaceholderMapping;
};
export function TranslationTable({
title,
mapping,
}: {
title: string;
mapping: PlaceholderMapping;
}) {
const groups = new Map<string, Array<[string, PlaceholderMapping[string]]>>();
for (const entry of Object.entries(mapping).sort(([a], [b]) =>
a.localeCompare(b),
)) {
const type = entry[1].type || "Sonstige";
const entries = groups.get(type) ?? [];
entries.push(entry);
groups.set(type, entries);
}
return (
<List
navigationTitle={`Übersetzungstabelle · ${title}`}
searchBarPlaceholder="Platzhalter oder Klartext suchen"
>
<List.EmptyView
title="Keine Zuordnungen"
description="Für diesen Stand sind noch keine Übersetzungen gespeichert."
/>
{[...groups]
.sort(([a], [b]) => a.localeCompare(b))
.map(([type, entries]) => (
<List.Section key={type} title={`${type} · ${entries.length}`}>
{entries.map(([placeholder, entry]) => (
<List.Item
key={placeholder}
icon={Icon.ArrowRight}
title={placeholder}
subtitle={entry.original}
keywords={[entry.original, entry.type, entry.merged_into ?? ""]}
accessories={
entry.merged_into ? [{ text: `→ ${entry.merged_into}` }] : []
}
actions={
<ActionPanel>
<Action.Push
title="Eintrag vollständig anzeigen"
icon={Icon.Eye}
target={
<Detail
navigationTitle={placeholder}
markdown={[
`# ${entry.type}`,
"",
"Platzhalter",
markdownCodeBlock(placeholder),
"",
"Klartext",
markdownCodeBlock(entry.original),
...(entry.merged_into
? [
"",
"Zusammengeführt mit",
markdownCodeBlock(entry.merged_into),
]
: []),
].join("\n")}
actions={
<ActionPanel>
<Action.CopyToClipboard
title="Platzhalter kopieren"
content={placeholder}
/>
<Action.CopyToClipboard
title="Klartext kopieren"
content={entry.original}
/>
</ActionPanel>
}
/>
}
/>
<Action.CopyToClipboard
title="Platzhalter kopieren"
content={placeholder}
/>
<Action.CopyToClipboard
title="Klartext kopieren"
content={entry.original}
/>
</ActionPanel>
}
/>
))}
</List.Section>
))}
</List>
);
}
export default function Command() {
const [projects, setProjects] = useState<TableSource[]>([]);
const [sessions, setSessions] = useState<TableSource[]>([]);
const [busy, setBusy] = useState(true);
useEffect(() => {
Promise.all([listAllRuns(), listSessions()])
.then(([runs, savedSessions]) => {
const latest = new Map<string, TableSource>();
for (const { run } of runs) {
const path = canonicalProjectPath(run.projectPath);
if (!latest.has(path))
latest.set(path, {
id: path,
title: run.projectName,
subtitle: `${path} · ${new Date(run.createdAt).toLocaleString("de-AT")}`,
mapping: run.mapping,
});
}
setProjects(
[...latest.values()].sort((a, b) => a.title.localeCompare(b.title)),
);
setSessions(
savedSessions.map((session) => ({
id: session.id,
title: session.name,
subtitle: `${Object.keys(session.mapping).length} Zuordnungen · ${new Date(session.updatedAt).toLocaleString("de-AT")}`,
mapping: session.mapping,
})),
);
})
.catch((error: Error) =>
showToast({
style: Toast.Style.Failure,
title: "Übersetzungstabellen nicht lesbar",
message: error.message,
}),
)
.finally(() => setBusy(false));
}, []);
return (
<List
isLoading={busy}
navigationTitle="Übersetzungstabellen"
searchBarPlaceholder="Projekt oder Sitzung suchen"
>
<List.EmptyView
title="Keine Tabellen"
description="Pseudonymisiere zuerst Text oder Projektdateien."
/>
<List.Section title="Projekte">
{projects.map((source) => (
<List.Item
key={source.id}
icon={Icon.Folder}
title={source.title}
subtitle={source.subtitle}
accessories={[
{ text: `${Object.keys(source.mapping).length} Zuordnungen` },
]}
actions={
<ActionPanel>
<Action.Push
title="Übersetzungstabelle anzeigen"
icon={Icon.List}
target={
<TranslationTable
title={source.title}
mapping={source.mapping}
/>
}
/>
</ActionPanel>
}
/>
))}
</List.Section>
<List.Section title="Sitzungen">
{sessions.map((source) => (
<List.Item
key={source.id}
icon={Icon.Person}
title={source.title}
subtitle={source.subtitle}
actions={
<ActionPanel>
<Action.Push
title="Übersetzungstabelle anzeigen"
icon={Icon.List}
target={
<TranslationTable
title={source.title}
mapping={source.mapping}
/>
}
/>
</ActionPanel>
}
/>
))}
</List.Section>
</List>
);
}

View File

@@ -11,7 +11,8 @@ import {
Toast, Toast,
useNavigation, useNavigation,
} from "@raycast/api"; } from "@raycast/api";
import { copyRichText, markdownToHtml, maybeCloseRaycast } from "./ai-views"; import { markdownToHtml, maybeCloseRaycast } from "./ai-views";
import { copyRichText } from "./rich-clipboard";
import { getSelectedTextSafely } from "./selection"; import { getSelectedTextSafely } from "./selection";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { import {

View File

@@ -0,0 +1,335 @@
import {
Action,
ActionPanel,
Clipboard,
Form,
Icon,
showToast,
Toast,
useNavigation,
} from "@raycast/api";
import { useEffect, useMemo, useState } from "react";
import {
type Creativity,
CREATIVITY_OPTIONS,
DEFAULT_MODEL,
getLastUsedModel,
MODEL_OPTIONS,
setLastUsedModel,
STRICT_PLACEHOLDER_RULE,
} from "./ai";
import { PseudonymizationConfirm } from "./ai-views";
import { getSelectedTextSafely } from "./selection";
import {
createSession,
getActiveSessionId,
getSession,
listSessions,
setActiveSession,
updateSessionMapping,
} from "./sessions";
import type { EntityType, VelumSession } from "./types";
import { NEW_SESSION_ID } from "./ui";
import {
getEntityTypes,
normalizePseudonymizeResponse,
pseudonymize,
} from "./velum";
function buildMeetingNotesPrompt(
pseudonymizedText: string,
instructions: string,
): string {
const extra = instructions.trim()
? `\n\nZusätzliche Anweisungen des Nutzers:\n${instructions.trim()}`
: "";
return [
"Du erhältst pseudonymisierte Meeting-Notizen als FLIESSTEXT / PROSA.",
"Personenbezogene Daten wurden bereits durch Platzhalter wie <PERSON_1>, <ORG_2>, <DATE_3>, <EMAIL_4> ersetzt.",
"",
STRICT_PLACEHOLDER_RULE,
"",
"Aufgabe: Verwandle den Fließtext in eine SAUBER STRUKTURIERTE, deutschsprachige Meeting-Zusammenfassung in Markdown.",
"Ziehe Struktur, Verantwortlichkeiten, Entscheidungen und Aufgaben aus der Prosa heraus — verdichte, ohne Inhalte zu erfinden.",
"",
"Verwende GENAU diese Abschnitte in dieser Reihenfolge:",
"",
"## Kontext",
"Ein kurzer Absatz (2–4 Sätze): Worum ging es im Meeting, Anlass, Rahmen. Datum/Ort nur, wenn aus dem Text ableitbar (sonst weglassen).",
"",
"## Teilnehmer",
"- Stichpunktliste der beteiligten Platzhalter, jeweils mit Rolle/Funktion (falls aus dem Text ableitbar).",
"- Format pro Zeile: `- <PERSON_X> — Rolle/Funktion`",
"",
"## Themen & Diskussion",
"Pro behandeltem Thema ein eigener Unter-Absatz:",
"### <Themenbezeichnung in 3–6 Wörtern>",
"Ein kurzer Absatz (2–5 Sätze), der Kernaussagen, Positionen und Argumente verdichtet. Wer hat was gesagt — nur wenn relevant. Keine wörtlichen Zitate, außer sie sind im Quelltext markiert.",
"",
"## Entscheidungen",
"- Eine Stichpunktliste mit jeweils einer klar formulierten Entscheidung pro Zeile.",
"- Nur tatsächlich getroffene Entscheidungen; keine Vorschläge oder Überlegungen.",
"",
"## Action Items",
"Markdown-Tabelle mit GENAU dieser Kopfzeile:",
"| Aufgabe | Verantwortlich | Deadline | Status |",
"| --- | --- | --- | --- |",
"- Status MUSS gesetzt sein, genau einer von: „offen“, „abgeschlossen“. „abgeschlossen“ nur wenn der Text die Erledigung explizit beschreibt.",
"- Verantwortlich als Platzhalter (z. B. <PERSON_1>); wenn nicht ableitbar: „—“.",
"- Deadline nur, wenn ein konkretes Datum/Zeitraum genannt ist; sonst leer lassen. Niemals Deadlines erfinden.",
"- Wenn keine Action Items vorkommen, schreibe stattdessen die einzelne Zeile: „Keine Action Items.“",
"",
"## Offene Punkte",
"- Stichpunktliste der Themen, die NICHT abschließend geklärt wurden bzw. Klärungsbedarf haben.",
"",
"## Nächste Schritte",
"Ein kurzer Absatz (1–3 Sätze): Wie geht es nach diesem Meeting weiter? Nur wenn aus dem Text ableitbar.",
"",
"Regeln:",
'Wenn ein Abschnitt im Text nicht vorkommt, schreibe „—" als einzigen Inhalt darunter.',
"Nichts erfinden, nichts ausschmücken — strikt am Quelltext bleiben.",
"Antworte ausschließlich in Markdown, ohne einleitende Floskeln und ohne umschließende Code-Fences (keine ``` Zeilen am Anfang oder Ende).",
extra,
"",
"--- Meeting-Notizen (pseudonymisiert, Prosa) ---",
pseudonymizedText,
"--- Ende ---",
].join("\n");
}
type FormValues = {
text: string;
sessionId: string;
entityTypes: string[];
model: string;
creativity: Creativity;
instructions: string;
};
export default function Command() {
const [text, setText] = useState("");
const [sessions, setSessions] = useState<VelumSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string>();
const [entityTypes, setEntityTypes] = useState<EntityType[]>([]);
const [selectedEntityTypes, setSelectedEntityTypes] = useState<string[]>([]);
const [model, setModel] = useState<string>(DEFAULT_MODEL);
const [isLoading, setIsLoading] = useState(true);
const { push } = useNavigation();
useEffect(() => {
async function load() {
const [loadedSessions, activeId, lastModel] = await Promise.all([
listSessions(),
getActiveSessionId(),
getLastUsedModel(),
]);
setSessions(loadedSessions);
setActiveSessionId(activeId);
setModel(lastModel);
const selection = await getSelectedTextSafely();
if (selection && selection.trim()) {
setText(selection);
}
try {
const loadedEntityTypes = await getEntityTypes();
setEntityTypes(loadedEntityTypes);
setSelectedEntityTypes(loadedEntityTypes);
} catch {
setEntityTypes([]);
} finally {
setIsLoading(false);
}
}
load();
}, []);
const defaultSessionId = useMemo(
() => activeSessionId || sessions[0]?.id || NEW_SESSION_ID,
[activeSessionId, sessions],
);
async function loadSelectedText() {
const selected = await getSelectedTextSafely();
if (!selected) {
await showToast({
style: Toast.Style.Failure,
title: "Kein markierter Text gefunden",
});
return;
}
setText(selected);
}
async function loadClipboardText() {
const clipboardText = await Clipboard.readText();
if (!clipboardText) {
await showToast({
style: Toast.Style.Failure,
title: "Zwischenablage enthält keinen Text",
});
return;
}
setText(clipboardText);
}
async function handleSubmit(values: FormValues) {
const input = values.text.trim();
if (!input) {
await showToast({
style: Toast.Style.Failure,
title: "Meeting-Notizen eingeben",
});
return;
}
void setLastUsedModel(values.model);
const toast = await showToast({
style: Toast.Style.Animated,
title: "Pseudonymisiere…",
});
try {
const session =
values.sessionId === NEW_SESSION_ID
? await createSession()
: ((await getSession(values.sessionId)) ?? (await createSession()));
await setActiveSession(session.id);
const rawResult = await pseudonymize(
input,
session.mapping,
values.entityTypes,
);
const pseudoResult = normalizePseudonymizeResponse(rawResult);
const updatedSession = await updateSessionMapping(
session.id,
pseudoResult.mapping,
pseudoResult.selected_entity_types,
);
toast.style = Toast.Style.Success;
toast.title = "Pseudonymisiert";
toast.message = `${pseudoResult.entity_count} Zuordnungen`;
push(
<PseudonymizationConfirm
session={updatedSession}
pseudonymizedText={pseudoResult.pseudonymized_text}
model={values.model}
creativity={values.creativity}
instructions={values.instructions}
buildPrompt={buildMeetingNotesPrompt}
labels={{
pageHeading: "Meeting-Zusammenfassung",
runningLabel: "KI strukturiert Meeting-Notizen …",
failureTitle: "Zusammenfassung fehlgeschlagen",
copyTitle: "Zusammenfassung kopieren",
pasteTitle: "Zusammenfassung einfügen",
}}
/>,
);
} catch (error) {
toast.style = Toast.Style.Failure;
toast.title = "Pseudonymisierung fehlgeschlagen";
toast.message = error instanceof Error ? error.message : String(error);
}
}
return (
<Form
isLoading={isLoading}
actions={
<ActionPanel>
<Action.SubmitForm
title="Meeting-Notizen strukturieren"
icon={Icon.Document}
onSubmit={handleSubmit}
/>
<Action
title="Markierten Text übernehmen"
icon={Icon.TextSelection}
onAction={loadSelectedText}
/>
<Action
title="Zwischenablage übernehmen"
icon={Icon.Clipboard}
onAction={loadClipboardText}
/>
</ActionPanel>
}
>
<Form.TextArea
id="text"
title="Meeting-Notizen (Prosa)"
value={text}
onChange={setText}
placeholder="Meeting-Notizen als Fließtext einfügen — z. B. handgeschriebene Protokollnotizen, ein diktiertes Transkript oder ein zusammenhängender Bericht."
/>
<Form.Dropdown
id="sessionId"
title="Sitzung"
defaultValue={defaultSessionId}
>
<Form.Dropdown.Item
value={NEW_SESSION_ID}
title="Neue Sitzung"
icon={Icon.Plus}
/>
{sessions.map((session) => (
<Form.Dropdown.Item
key={session.id}
value={session.id}
title={session.name}
icon={
session.id === activeSessionId ? Icon.CheckCircle : Icon.Circle
}
/>
))}
</Form.Dropdown>
{entityTypes.length > 0 ? (
<Form.TagPicker
id="entityTypes"
title="Entitätstypen"
value={selectedEntityTypes}
onChange={setSelectedEntityTypes}
>
{entityTypes.map((entityType) => (
<Form.TagPicker.Item
key={entityType}
value={entityType}
title={entityType}
/>
))}
</Form.TagPicker>
) : null}
<Form.Dropdown
id="model"
title="KI-Modell"
value={model}
onChange={setModel}
>
{MODEL_OPTIONS.map((option) => (
<Form.Dropdown.Item
key={option.value}
value={option.value}
title={option.title}
/>
))}
</Form.Dropdown>
<Form.Dropdown id="creativity" title="Kreativität" defaultValue="low">
{CREATIVITY_OPTIONS.map((value) => (
<Form.Dropdown.Item key={value} value={value} title={value} />
))}
</Form.Dropdown>
<Form.TextArea
id="instructions"
title="Zusätzliche Anweisungen"
placeholder="Optional: Fokus auf bestimmte Themen, Tonalität, Zielpublikum (z. B. „für Lenkungsausschuss verdichten“), …"
/>
</Form>
);
}

View File

@@ -3,6 +3,7 @@ export type EntityType = string;
export type MappingEntry = { export type MappingEntry = {
original: string; original: string;
type: EntityType; type: EntityType;
merged_into?: string | null;
}; };
export type PlaceholderMapping = Record<string, MappingEntry>; export type PlaceholderMapping = Record<string, MappingEntry>;

View File

@@ -108,6 +108,7 @@ export async function pseudonymize(
text: string, text: string,
mapping: PlaceholderMapping, mapping: PlaceholderMapping,
entityTypes?: EntityType[], entityTypes?: EntityType[],
forceMasks?: Record<string, string[]>,
): Promise<PseudonymizeResponse> { ): Promise<PseudonymizeResponse> {
const response = await fetchWithAuth(apiUrl("/api/pseudonymize"), { const response = await fetchWithAuth(apiUrl("/api/pseudonymize"), {
method: "POST", method: "POST",
@@ -117,14 +118,81 @@ export async function pseudonymize(
body: JSON.stringify({ body: JSON.stringify({
text, text,
mapping, mapping,
entity_types: entity_types: entityTypes,
entityTypes && entityTypes.length > 0 ? entityTypes : undefined, force_masks: forceMasks,
}), }),
}); });
return parseJsonResponse<PseudonymizeResponse>(response); return parseJsonResponse<PseudonymizeResponse>(response);
} }
export type DocumentHit = {
id: string;
source: string;
context: string;
start: number;
end: number;
type: string;
score: number;
placeholder: string;
original: string;
};
export async function requireProjectCapabilities(): Promise<void> {
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<Record<string, boolean>>(response);
if (!capabilities.request_force_masks || !capabilities.document_hit_review
|| !capabilities.document_restoration || !capabilities.comment_author_review
|| !capabilities.revision_author_review) {
throw new Error("Die Velum-API unterstützt die Projektfunktionen noch nicht vollständig.");
}
}
export type DocumentResult = {
filename: string;
content_type: string;
file_base64: string;
mapping: PlaceholderMapping;
active_placeholders: string[];
hits: DocumentHit[];
};
export async function pseudonymizeDocument(
filename: string,
data: Buffer,
mapping: PlaceholderMapping,
forceMasks: Record<string, string[]>,
acceptedHitIds?: string[],
entityTypes?: EntityType[],
): Promise<DocumentResult> {
const form = new FormData();
form.set("file", new Blob([new Uint8Array(data)]), filename);
form.set("mapping", JSON.stringify(mapping));
form.set("force_masks", JSON.stringify(forceMasks));
if (entityTypes !== undefined) form.set("entity_types", JSON.stringify(entityTypes));
if (acceptedHitIds) form.set("accepted_hit_ids", JSON.stringify(acceptedHitIds));
const response = await fetchWithAuth(apiUrl("/api/pseudonymize-document"), {
method: "POST", body: form,
});
return parseJsonResponse<DocumentResult>(response);
}
export async function depseudonymizeDocument(
filename: string,
data: Buffer,
mapping: PlaceholderMapping,
): Promise<Buffer> {
const form = new FormData();
form.set("file", new Blob([new Uint8Array(data)]), filename);
form.set("mapping", JSON.stringify(mapping));
const response = await fetchWithAuth(apiUrl("/api/depseudonymize-document"), {
method: "POST", body: form,
});
const result = await parseJsonResponse<{ file_base64: string }>(response);
return Buffer.from(result.file_base64, "base64");
}
export async function depseudonymize( export async function depseudonymize(
text: string, text: string,
mapping: PlaceholderMapping, mapping: PlaceholderMapping,

96
tests/migration.test.ts Normal file
View File

@@ -0,0 +1,96 @@
import { afterEach, expect, it, mock } from "bun:test";
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { migrate } from "../tools/migrate-ki-datenschutz.mjs";
mock.module("@raycast/api", () => ({ environment: { supportPath: tmpdir() } }));
const { restorePath, restoreText } = await import("../src/lib/velum-project");
const roots: string[] = [];
afterEach(async () => { await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); });
it("migriert einen künstlichen Altbestand verlustarm ohne den Quellordner zu ändern", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-legacy-test-"));
roots.push(root);
const project = join(root, "Projekt");
const targetSupport = join(root, "VelumSupport");
const oldSupport = join(root, "OldSupport");
const previewFile = join(root, "Migrationsvorschau.md");
await fs.mkdir(project);
await fs.mkdir(join(oldSupport, "projects"), { recursive: true });
const legacyName = "KUNDE ALPHA.txt";
const legacyContent = "kundeAlphaService und KUNDE ALPHA";
await fs.writeFile(join(project, legacyName), legacyContent);
await fs.writeFile(join(project, "Handbuch.docx"), "office");
await fs.writeFile(join(project, "leer.txt"), "Ohne Platzhalter");
const projectId = createHash("sha1").update(project).digest("hex").slice(0, 16);
const manifestFile = join(oldSupport, "projects", `${projectId}.json`);
await fs.writeFile(manifestFile, JSON.stringify({
version: 1, projectPath: project, projectTerms: [{ id: "t1", term: "Müller GmbH", category: "Kunde", enabled: true, aliases: [] }],
variantTable: [
{ termId: "t1", termVariant: "Müller GmbH", placeholderVariant: "KUNDE ALPHA", style: "typed", boundary: "strict" },
{ termId: "t1", termVariant: "muellerGmbh", placeholderVariant: "kundeAlpha", style: "camel", boundary: "identifier" },
],
}));
const preview = await migrate({ project, manifestFile, targetSupport, previewFile });
expect(preview.applied).toBe(false);
expect(preview.summary.files).toBe(2);
expect(preview.summary.skipped).toBe(1);
const report = await fs.readFile(previewFile, "utf8");
expect(report).toContain('"KUNDE ALPHA.txt"');
expect(report).toContain('"KUNDE_100001.txt"');
expect(report).toContain('"kundeAlpha" → "KUNDE_100002" (1×)');
expect(report).toContain('"Handbuch.docx": Office- oder PDF-Datei');
expect(report).toContain("Keine Änderung.");
expect(report).not.toContain("Müller GmbH");
expect((await fs.stat(previewFile)).mode & 0o777).toBe(0o600);
expect(await fs.readFile(join(project, legacyName), "utf8")).toBe(legacyContent);
expect(await fs.readdir(root)).not.toContain("VelumSupport");
await expect(migrate({ project, manifestFile, targetSupport, previewFile })).rejects.toMatchObject({ code: "EEXIST" });
await expect(migrate({ project, manifestFile, targetSupport, previewFile, apply: true })).rejects.toThrow();
await expect(migrate({ project, manifestFile, targetSupport,
previewFile: join(project, "vorschau.md") })).rejects.toThrow();
const result = await migrate({ project, manifestFile, targetSupport, apply: true });
expect(result.applied).toBe(true);
const run = JSON.parse(await fs.readFile(join(result.runDir!, "run.json"), "utf8"));
const file = run.files[0];
const content = await fs.readFile(join(result.runDir!, "files", file.output), "utf8");
expect(restoreText(content, run.mapping, run.matchModes)).toBe("muellerGmbhService und Müller GmbH");
expect(restorePath(file.output, run.mapping, run.matchModes)).toBe("Müller GmbH.txt");
expect(await fs.readFile(join(project, legacyName), "utf8")).toBe(legacyContent);
});
it("ersetzt Platzhalter hinter URL-kodierten Pfadtrennern im Dateinamen", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-encoded-name-test-"));
roots.push(root);
const project = join(root, "Projekt");
const targetSupport = join(root, "VelumSupport");
const manifestFile = join(root, "OldSupport", "projects", "manifest.json");
const previewFile = join(root, "Vorschau.md");
await fs.mkdir(join(project, "KUNDE_A"), { recursive: true });
await fs.mkdir(join(root, "OldSupport", "projects"), { recursive: true });
const source = "KUNDE_A/%2fkunde_a%2fzc.asddls";
await fs.writeFile(join(project, source), "Technischer Inhalt");
await fs.writeFile(manifestFile, JSON.stringify({
version: 1, projectPath: project,
projectTerms: [{ id: "t1", term: "Alpha GmbH", category: "Kunde", enabled: true, aliases: [] }],
variantTable: [
{ termId: "t1", termVariant: "Alpha GmbH", placeholderVariant: "KUNDE_A", style: "typed", boundary: "strict" },
{ termId: "t1", termVariant: "alpha_gmbh", placeholderVariant: "kunde_a", style: "snake", boundary: "strict" },
],
}));
const preview = await migrate({ project, manifestFile, targetSupport, previewFile });
expect(preview.summary.replacements).toBe(2);
const report = await fs.readFile(previewFile, "utf8");
expect(report).toContain('"KUNDE_A/%2fkunde_a%2fzc.asddls"');
expect(report).toContain('"KUNDE_100001/%2fKUNDE_100002%2fzc.asddls"');
expect(report).not.toContain("Alpha GmbH");
const result = await migrate({ project, manifestFile, targetSupport, apply: true });
const run = JSON.parse(await fs.readFile(join(result.runDir!, "run.json"), "utf8"));
expect(restorePath(run.files[0].output, run.mapping, run.matchModes))
.toBe("Alpha GmbH/%2falpha_gmbh%2fzc.asddls");
expect(await fs.readFile(join(project, source), "utf8")).toBe("Technischer Inhalt");
});

View File

@@ -0,0 +1,96 @@
import { expect, it } from "bun:test";
import {
canonicalPerson, filenameScanText, groupReviewHits, makeTextBatches, mappingForSelection, mergePeople,
spansBySegment, withoutPlaceholderHits, 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" },
{ key: "zwei.txt", text: "Max und Max" },
]);
expect(batch.segments).toHaveLength(2);
const second = batch.segments[1];
const spans = spansBySegment(batch, [
{ start: 2, end: 5, original: "Max" },
{ start: second.start, end: second.start + 3, original: "Max" },
{ start: batch.segments[0].end, end: second.start + 1, original: "Grenztreffer" },
]);
expect(spans.get("eins.txt")).toEqual([{ start: 2, end: 5, original: "Max" }]);
expect(spans.get("zwei.txt")).toEqual([{ start: 0, end: 3, original: "Max" }]);
expect(makeTextBatches([{ key: "a", text: "abc" }, { key: "b", text: "def" }], 5)).toHaveLength(2);
});
it("zeigt gleiche Zuordnungen einmal mit der Anzahl ihrer Vorkommen", () => {
const hits = [
{ id: "a", type: "PERSON", original: "Max", placeholder: "PERSON_01", file: "a.txt" },
{ id: "b", type: "PERSON", original: "Max", placeholder: "PERSON_01", file: "b.txt" },
{ id: "c", type: "PERSON", original: "Max", placeholder: "PERSON_02", file: "c.txt" },
{ id: "d", type: "ORT", original: "Max", placeholder: "ORT_01", file: "a.txt" },
];
const groups = groupReviewHits(hits);
expect(groups).toHaveLength(3);
expect(groups.find((group) => group.placeholder === "PERSON_01")?.hits).toHaveLength(2);
expect(groups.find((group) => group.placeholder === "PERSON_01")?.hits.map((hit) => hit.id)).toEqual(["a", "b"]);
});
it("unterdrückt projektweit nur exakt abgewählte Begriffe samt erzwungener Regel", () => {
const whitelist = [{ type: "PERSON", original: " Max Muster " }];
const hits = [
{ id: "1", type: "PERSON", original: "max muster", placeholder: "PERSON_01" },
{ id: "2", type: "ORG", original: "Max Muster", placeholder: "ORG_01" },
{ id: "3", type: "PERSON", original: "Max Mustermann", placeholder: "PERSON_02" },
];
expect(withoutWhitelistedHits(hits, whitelist).map((hit) => hit.id)).toEqual(["2", "3"]);
expect(withoutWhitelistedRules([
{ type: "PERSON", value: "Max Muster" }, { type: "ORG", value: "Max Muster" },
], whitelist)).toEqual([{ type: "ORG", value: "Max Muster" }]);
});
it("zeigt beim erneuten Lauf keine Treffer, die einen bestehenden Platzhalter einschließen", () => {
const hits = [
{ type: "PERSON", original: "PERSON_39, Roman" },
{ type: "KUNDE", original: "KUNDE_100005" },
{ type: "PERSON", original: "Roman" },
{ type: "PERSON", original: "Anlage44" },
{ type: "PASSWORT", original: "ORT_08!" },
];
expect(withoutPlaceholderHits(hits)).toEqual(hits.slice(2));
expect(withoutPlaceholderHits([
{ type: "PERSON", original: "KUNDE_100005Service" },
{ type: "PERSON", original: "Roman" },
], ["KUNDE_100005"])).toEqual([{ type: "PERSON", original: "Roman" }]);
});
it("fasst ausgewählte Personen zu einem kanonischen Platzhalter zusammen und bewahrt alte Zuordnungen", () => {
let merges = mergePeople({}, "PERSON_02", "PERSON_01");
merges = mergePeople(merges, "PERSON_03", "PERSON_02");
expect(canonicalPerson("PERSON_03", merges)).toBe("PERSON_01");
const previous = { PERSON_09: { original: "Alt", type: "PERSON" } };
const preview = {
...previous,
PERSON_01: { original: "Franz Fuchs", type: "PERSON" },
PERSON_02: { original: "Franz", type: "PERSON" },
PERSON_03: { original: "Hr. Fuchs", type: "PERSON" },
PERSON_04: { original: "Fehlalarm", type: "PERSON" },
};
const accepted = ["PERSON_01", "PERSON_02", "PERSON_03"].map((placeholder) => ({
id: placeholder, type: "PERSON", original: preview[placeholder as keyof typeof preview].original, placeholder,
}));
const mapping = mappingForSelection(preview, previous, accepted, merges);
expect(mapping.PERSON_02.merged_into).toBe("PERSON_01");
expect(mapping.PERSON_03.merged_into).toBe("PERSON_01");
expect(mapping.PERSON_09.original).toBe("Alt");
expect(mapping.PERSON_04).toBeUndefined();
});

View File

@@ -0,0 +1,147 @@
import { afterEach, describe, expect, it, mock } from "bun:test";
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
mock.module("@raycast/api", () => ({ environment: { supportPath: tmpdir() } }));
const project = await import("../src/lib/velum-project");
const revision = await import("../src/lib/project-revision");
const roots: string[] = [];
afterEach(async () => { await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); });
describe("Nachbearbeitung von Projektständen", () => {
it("gibt eine Person frei und führt zwei andere nachträglich zusammen", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-revision-"));
roots.push(root);
const source = join(root, "Quelle");
const support = join(root, "Raycast");
await fs.mkdir(source);
const saved = await project.createRun(source, support);
saved.run.mapping = {
PERSON_01: { type: "PERSON", original: "Falsch Positiv" },
PERSON_02: { type: "PERSON", original: "Franz Fuchs" },
PERSON_03: { type: "PERSON", original: "Hr. Fuchs" },
PERSON_04: { type: "PERSON", original: "Franz F." },
};
saved.run.files = [{ source: "Bericht.txt", output: "PERSON_01-PERSON_03.txt", kind: "text" }];
await fs.writeFile(join(saved.directory, "files", "PERSON_01-PERSON_03.txt"),
"PERSON_01 traf PERSON_02 und PERSON_03. PERSON_04 blieb.");
await project.saveRun(saved.directory, saved.run);
const result = await revision.reviseProjectRun({
previous: saved.run, previousDirectory: saved.directory, support,
release: new Set(["PERSON_01"]), merges: { PERSON_03: "PERSON_02", PERSON_04: "PERSON_02" },
rewriteDocument: async () => { throw new Error("Unerwartetes Office-Dokument"); },
});
expect(result.run.files[0].output).toBe("Falsch Positiv-PERSON_02.txt");
expect(await fs.readFile(join(result.directory, "files", result.run.files[0].output), "utf8"))
.toBe("Falsch Positiv traf PERSON_02 und PERSON_02. PERSON_02 blieb.");
expect(result.run.copyPath?.startsWith(join(root, "Quelle-Velum-"))).toBe(true);
expect(await fs.readFile(join(result.run.copyPath!, result.run.files[0].output), "utf8"))
.toBe("Falsch Positiv traf PERSON_02 und PERSON_02. PERSON_02 blieb.");
expect(result.run.mapping.PERSON_03.merged_into).toBe("PERSON_02");
expect(result.run.mapping.PERSON_04.merged_into).toBe("PERSON_02");
expect(result.run.mapping.PERSON_01).toBeUndefined();
expect(await project.loadProjectWhitelist(source, support))
.toEqual([{ type: "PERSON", original: "Falsch Positiv" }]);
expect(await fs.readFile(join(saved.directory, "files", "PERSON_01-PERSON_03.txt"), "utf8"))
.toContain("PERSON_03");
});
it("gibt eine konsolidierte Person samt Varianten frei", () => {
const plan = revision.planProjectRevision({
PERSON_01: { type: "PERSON", original: "Franz Fuchs" },
PERSON_02: { type: "PERSON", original: "Franz F.", merged_into: "PERSON_01" },
PERSON_03: { type: "PERSON", original: "Hr. Fuchs", merged_into: "PERSON_01" },
}, new Set(["PERSON_01"]), {});
expect(plan.released).toEqual(["PERSON_01", "PERSON_02", "PERSON_03"]);
expect(plan.whitelist.map((item) => item.original)).toEqual(["Franz Fuchs", "Franz F.", "Hr. Fuchs"]);
expect(plan.mapping).toEqual({});
});
it("ersetzt einen vorhandenen Projektordner nur, wenn er noch dem letzten Stand entspricht", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-revision-place-"));
roots.push(root);
const source = join(root, "Quelle");
const support = join(root, "Raycast");
await fs.mkdir(source);
const saved = await project.createRun(source, support);
saved.run.mapping = { PERSON_01: { type: "PERSON", original: "Falscher Treffer" } };
saved.run.files = [{ source: "Notiz.txt", output: "PERSON_01.txt", kind: "text" }];
saved.run.inPlace = true;
await fs.writeFile(join(saved.directory, "files", "PERSON_01.txt"), "PERSON_01");
await fs.writeFile(join(source, "PERSON_01.txt"), "PERSON_01");
await project.saveRun(saved.directory, saved.run);
await fs.writeFile(join(source, "PERSON_01.txt"), "PERSON_01 mit lokaler Änderung");
await expect(revision.reviseProjectRun({ previous: saved.run, previousDirectory: saved.directory,
release: new Set(["PERSON_01"]), merges: {}, support,
rewriteDocument: async () => { throw new Error("Unerwartetes Office-Dokument"); },
})).rejects.toThrow("seit dem letzten Stand geändert");
expect((await project.listRuns(source, support)).length).toBe(1);
expect(await fs.readFile(join(source, "PERSON_01.txt"), "utf8"))
.toBe("PERSON_01 mit lokaler Änderung");
await fs.writeFile(join(source, "PERSON_01.txt"), "PERSON_01");
const result = await revision.reviseProjectRun({ previous: saved.run, previousDirectory: saved.directory,
release: new Set(["PERSON_01"]), merges: {}, support,
rewriteDocument: async () => { throw new Error("Unerwartetes Office-Dokument"); },
});
expect(await fs.readFile(join(source, "Falscher Treffer.txt"), "utf8")).toBe("Falscher Treffer");
expect(await fs.readFile(join(result.directory, "backup", "PERSON_01.txt"), "utf8")).toBe("PERSON_01");
});
it("übergibt Office-Dateien mit einer gezielten Ersetzung an die Dokumentfunktion", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-revision-doc-"));
roots.push(root);
const source = join(root, "Quelle");
const support = join(root, "Raycast");
await fs.mkdir(source);
const saved = await project.createRun(source, support);
saved.run.mapping = {
PERSON_01: { type: "PERSON", original: "Falscher Treffer" },
PERSON_02: { type: "PERSON", original: "Echte Person" },
};
saved.run.files = [{ source: "Bericht.docx", output: "Bericht.docx", kind: "document" }];
await fs.writeFile(join(saved.directory, "files", "Bericht.docx"), "synthetic office bytes");
await project.saveRun(saved.directory, saved.run);
let called = false;
const result = await revision.reviseProjectRun({ previous: saved.run, previousDirectory: saved.directory,
release: new Set(["PERSON_01"]), merges: {}, support,
rewriteDocument: async (filename, data, mapping) => {
called = true;
expect(filename).toBe("Bericht.docx");
expect(data.toString()).toBe("synthetic office bytes");
expect(Object.keys(mapping)).toEqual(["PERSON_01"]);
return Buffer.from("changed office bytes");
},
});
expect(called).toBe(true);
expect(await fs.readFile(join(result.directory, "files", "Bericht.docx"), "utf8"))
.toBe("changed office bytes");
});
it("schützt längere importierte Platzhalter beim Freigeben eines Präfixes", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-revision-prefix-"));
roots.push(root);
const source = join(root, "Quelle");
const support = join(root, "Raycast");
await fs.mkdir(source);
const saved = await project.createRun(source, support);
saved.run.origin = "imported";
saved.run.mapping = {
BEGRIFF_100001: { type: "BEGRIFF", original: "Alpha" },
BEGRIFF_1000012: { type: "BEGRIFF", original: "Beta" },
};
saved.run.matchModes = { BEGRIFF_100001: "anywhere", BEGRIFF_1000012: "anywhere" };
saved.run.files = [{ source: "Code.txt", output: "BEGRIFF_1000012.txt", kind: "text" }];
await fs.writeFile(join(saved.directory, "files", "BEGRIFF_1000012.txt"),
"xBEGRIFF_1000012 und xBEGRIFF_100001");
await project.saveRun(saved.directory, saved.run);
const result = await revision.reviseProjectRun({ previous: saved.run, previousDirectory: saved.directory,
release: new Set(["BEGRIFF_100001"]), merges: {}, support,
rewriteDocument: async () => { throw new Error("Unerwartetes Office-Dokument"); },
});
expect(result.run.origin).toBe("imported");
expect(result.run.files[0].output).toBe("BEGRIFF_1000012.txt");
expect(await fs.readFile(join(result.directory, "files", result.run.files[0].output), "utf8"))
.toBe("xBEGRIFF_1000012 und xAlpha");
});
});

89
tests/rich-text.test.ts Normal file
View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "bun:test";
import { restoreRichHtml } from "../src/lib/rich-text";
const mapping = {
PERSON_01: { original: "Max Mustermann", type: "PERSON" },
ORG_01: { original: "Müller & Söhne", type: "ORG" },
};
describe("formatierte Textwiederherstellung", () => {
it("behält Formatierung bei und maskiert Originale HTML-sicher", () => {
const html = "<p>Hallo <strong>PERSON_01</strong> bei <em>ORG_01</em>.</p>";
expect(
restoreRichHtml(
html,
"Hallo PERSON_01 bei ORG_01.",
"Hallo Max Mustermann bei Müller & Söhne.",
mapping,
),
).toBe(
"<p>Hallo <strong>Max Mustermann</strong> bei <em>Müller &amp; Söhne</em>.</p>",
);
});
it("ändert weder Attribute noch unsichtbare Inhalte", () => {
const html =
'<div data-label="PERSON_01"><style>.PERSON_01{color:red}</style><b>PERSON_01</b></div>';
expect(restoreRichHtml(html, "PERSON_01", "Max Mustermann", mapping)).toBe(
'<div data-label="PERSON_01"><style>.PERSON_01{color:red}</style><b>Max Mustermann</b></div>',
);
});
it("übernimmt kodierte Sitzungsplatzhalter", () => {
const html = "<span>&lt;PERSON_01&gt;</span>";
expect(
restoreRichHtml(
html,
"<PERSON_01>",
"Max Mustermann",
mapping,
undefined,
true,
),
).toBe("<span>Max Mustermann</span>");
});
it("verwirft nicht passende oder über mehrere Textknoten verteilte HTML-Daten", () => {
expect(
restoreRichHtml(
"<b>PERSON_01</b>",
"anderer Text",
"Max Mustermann",
mapping,
),
).toBeUndefined();
expect(
restoreRichHtml(
"<b>PERSON_</b><i>01</i>",
"PERSON_01",
"Max Mustermann",
mapping,
),
).toBeUndefined();
});
it("beachtet projektbezogene Ersetzung innerhalb zusammengesetzter Begriffe", () => {
const html = "<b>abcPERSON_01xyz</b>";
expect(
restoreRichHtml(
html,
"abcPERSON_01xyz",
"abcMax Mustermannxyz",
mapping,
{ PERSON_01: "anywhere" },
),
).toBe("<b>abcMax Mustermannxyz</b>");
});
it("erhält HTML-Formatierung auch bei anders geschriebenen Platzhaltern", () => {
const html = "<b>person_01</b> und <i>Person_01</i>";
expect(restoreRichHtml(html, "person_01 und Person_01",
"max mustermann und Max Mustermann", mapping))
.toBe("<b>max mustermann</b> und <i>Max Mustermann</i>");
expect(restoreRichHtml("<b>Person_01</b> / <i>PERSON_01</i>",
"Person_01 / PERSON_01", "Person_01 / Eins", {
PERSON_01: { original: "Eins", type: "PERSON" },
person_01: { original: "Zwei", type: "PERSON" },
})).toBe("<b>Person_01</b> / <i>Eins</i>");
});
});

224
tests/velum-project.test.ts Normal file
View File

@@ -0,0 +1,224 @@
import { afterEach, describe, expect, it, mock } from "bun:test";
import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { RunFile } from "../src/lib/velum-project";
mock.module("@raycast/api", () => ({ environment: { supportPath: tmpdir() } }));
const project = await import("../src/lib/velum-project");
const roots: string[] = [];
afterEach(async () => { await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); });
describe("Velum-Projektstände", () => {
it("speichert Projektregeln und Bearbeitungsstände getrennt vom Quellordner", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-project-test-"));
roots.push(root);
const source = join(root, "Quelle");
const support = join(root, "Raycast");
await fs.mkdir(source);
await fs.writeFile(join(source, "Notiz.txt"), "Max Mustermann");
const rules = project.parseProjectRules("PERSON: Max Mustermann\nKundenSuite");
await project.saveProjectRules(source, rules, support);
expect(await project.loadProjectRules(source, support)).toEqual(rules);
await project.saveProjectWhitelist(source, [
{ type: "PERSON", original: "Falscher Treffer" },
{ type: "PERSON", original: "falscher treffer" },
], support);
expect(await project.loadProjectWhitelist(source, support)).toEqual([
{ type: "PERSON", original: "falscher treffer" },
]);
expect(await fs.readFile(join(source, "Notiz.txt"), "utf8")).toBe("Max Mustermann");
const created = await project.createRun(source, support);
created.run.mapping = { PERSON_01: { original: "Max Mustermann", type: "PERSON" } };
created.run.files.push({ source: "Notiz.txt", output: "PERSON_01.txt", kind: "text" });
await project.saveRun(created.directory, created.run);
expect((await project.listRuns(source, support))[0].run.files[0].output).toBe("PERSON_01.txt");
expect(await fs.readFile(join(source, "Notiz.txt"), "utf8")).toBe("Max Mustermann");
});
it("ordnet erneute Läufe demselben Projektpfad zu und erkennt ältere Pfadschreibweisen", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-project-identity-test-"));
roots.push(root);
const source = join(root, "Quelle");
const support = join(root, "Raycast");
await fs.mkdir(source);
const first = await project.createRun(`${source}/`, support);
first.run.projectPath = `${source}/`;
await project.saveRun(first.directory, first.run);
const second = await project.createRun(join(source, "."), support);
await project.saveRun(second.directory, second.run);
expect(project.projectDataRoot(source, support)).toBe(project.projectDataRoot(`${source}/`, support));
expect(first.run.id).not.toBe(second.run.id);
expect((await project.listRuns(source, support)).map(({ run }) => run.id).sort())
.toEqual([first.run.id, second.run.id].sort());
});
it("legt pseudonymisierte Kopien neben dem Projekt ab und behält die Tabelle privat", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-sibling-copy-test-"));
roots.push(root);
const source = join(root, "Quelle");
const support = join(root, "Raycast");
await fs.mkdir(source);
const created = await project.createRun(source, support);
created.run.mapping = { PERSON_01: { original: "Max Mustermann", type: "PERSON" } };
created.run.files = [{ source: "Unterordner/Name.txt", output: "Unterordner/PERSON_01.txt", kind: "text" }];
await fs.mkdir(join(created.directory, "files", "Unterordner"));
await fs.writeFile(join(created.directory, "files", "Unterordner", "PERSON_01.txt"), "PERSON_01");
const copyPath = await project.publishProjectCopies(created.run, created.directory);
created.run.copyPath = copyPath;
await project.saveRun(created.directory, created.run);
expect(copyPath.startsWith(join(root, "Quelle-Velum-"))).toBe(true);
expect(project.runCopiesPath(created.run, created.directory)).toBe(copyPath);
expect(await fs.readFile(join(copyPath, "Unterordner", "PERSON_01.txt"), "utf8")).toBe("PERSON_01");
expect(await fs.readdir(copyPath)).toEqual(["Unterordner"]);
expect(await fs.readFile(join(created.directory, "run.json"), "utf8")).toContain("Max Mustermann");
await fs.writeFile(join(copyPath, "Antwort.txt"), "PERSON_01 antwortet");
const current = await project.scanRestorableCopyFiles(created.run, created.directory);
expect(current.root).toBe(copyPath);
expect(current.files.map((file) => file.relPath)).toEqual(["Antwort.txt", "Unterordner/PERSON_01.txt"]);
const history = await project.scanRestorableCopyFiles(created.run, created.directory, false);
expect(history.root).toBe(join(created.directory, "files"));
expect(history.files.map((file) => file.relPath)).toEqual(["Unterordner/PERSON_01.txt"]);
expect(project.restoreTextWithCount("PERSON_01 antwortet", created.run.mapping))
.toEqual({ text: "Max Mustermann antwortet", replacements: 1 });
await expect(project.publishProjectCopies(created.run, created.directory)).rejects.toThrow("bereits vorhanden");
expect((await project.listRuns(source, support))[0].run.copyPath).toBe(copyPath);
});
it("behält die Kategorieauswahl pro Projekt für den nächsten Lauf", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-entity-types-test-"));
roots.push(root);
const source = join(root, "Quelle");
const other = join(root, "Anderes Projekt");
const support = join(root, "Raycast");
await fs.mkdir(source);
await fs.mkdir(other);
expect(await project.loadProjectEntityTypes(source, support)).toBeNull();
await project.saveProjectEntityTypes(`${source}/`, ["PERSON", "ORG", "PERSON"], support);
expect(await project.loadProjectEntityTypes(source, support)).toEqual(["PERSON", "ORG"]);
expect(await project.loadProjectEntityTypes(other, support)).toBeNull();
});
it("stellt aktuelle In-Place-Dateien aus dem Projektordner und frühere Stände aus dem Snapshot bereit", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-current-source-test-"));
roots.push(root);
const source = join(root, "Projekt");
const support = join(root, "Raycast");
await fs.mkdir(source);
const created = await project.createRun(source, support);
created.run.inPlace = true;
created.run.files = [{ source: "Notiz.txt", output: "Notiz.txt", kind: "text" }];
await fs.writeFile(join(created.directory, "files", "Notiz.txt"), "PERSON_01");
await fs.writeFile(join(source, "Notiz.txt"), "PERSON_01");
await fs.writeFile(join(source, "Antwort.txt"), "PERSON_01 antwortet");
const current = await project.scanRestorableCopyFiles(created.run, created.directory);
expect(current.root).toBe(source);
expect(current.files.map((file) => file.relPath)).toEqual(["Antwort.txt", "Notiz.txt"]);
const historical = await project.scanRestorableCopyFiles(created.run, created.directory, false);
expect(historical.root).toBe(join(created.directory, "files"));
expect(historical.files.map((file) => file.relPath)).toEqual(["Notiz.txt"]);
});
it("ersetzt nur bestätigte Vorkommen und stellt Dateinamen wieder her", () => {
const hits = [
{ id: "a", start: 0, end: 3, placeholder: "PERSON_01" },
{ id: "b", start: 8, end: 11, placeholder: "PERSON_01" },
];
expect(project.replaceSpans("Max und Max", hits, new Set(["a"]))).toBe("PERSON_01 und Max");
expect(project.restorePath("PERSON_01_Bericht.txt", {
PERSON_01: { original: "Max Mustermann", type: "PERSON" },
})).toBe("Max Mustermann_Bericht.txt");
expect(project.restorePath("Archiv/BerichtPERSON_01.docx", {
PERSON_01: { original: "Max Mustermann", type: "PERSON" },
})).toBe("Archiv/BerichtMax Mustermann.docx");
expect(project.restorePath("archiv/bericht_person_01.docx", {
PERSON_01: { original: "Max Mustermann", type: "PERSON" },
})).toBe("archiv/bericht_max mustermann.docx");
expect(project.restoreTextWithCount("person_01 und Person_01", {
PERSON_01: { original: "Max Mustermann", type: "PERSON" },
})).toEqual({ text: "max mustermann und Max Mustermann", replacements: 2 });
expect(project.restoreText("PERSON_01 / person_01 / Person_01", {
PERSON_01: { original: "Max Mustermann", type: "PERSON" },
})).toBe("Max Mustermann / max mustermann / Max Mustermann");
expect(project.restoreText("KUNDE_01 / kunde_01 / Kunde_01", {
KUNDE_01: { original: "ACME GMBH", type: "KUNDE" },
})).toBe("ACME GMBH / acme gmbh / Acme Gmbh");
expect(project.restoreText("person_01 / PERSON_01", {
person_01: { original: "max mustermann", type: "PERSON" },
})).toBe("max mustermann / MAX MUSTERMANN");
expect(project.restoreText("BerichtPERSON_01", {
PERSON_01: { original: "Max Mustermann", type: "PERSON" },
})).toBe("BerichtPERSON_01");
expect(project.restorePath("PERSON_0101.txt", {
PERSON_01: { original: "Falsch", type: "PERSON" },
PERSON_0101: { original: "Richtig", type: "PERSON" },
})).toBe("Richtig.txt");
expect(project.restoreText("Person_01 / PERSON_01", {
PERSON_01: { original: "Eins", type: "PERSON" },
person_01: { original: "Zwei", type: "PERSON" },
})).toBe("Person_01 / Eins");
expect(() => project.safeRelativePath("../geheim.txt")).toThrow();
});
it("übernimmt API-Trefferpositionen auch nach einem Emoji korrekt", () => {
const text = "😀 Max";
const spans = project.toUtf16Offsets(text, [{ id: "max", start: 2, end: 5, placeholder: "PERSON_01" }]);
expect(project.replaceSpans(text, spans, new Set(["max"]))).toBe("😀 PERSON_01");
});
it("ersetzt ausgewählte Projektdateien vor Ort und sichert die Originale außerhalb", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-in-place-test-"));
roots.push(root);
const source = join(root, "Quelle");
const support = join(root, "Raycast");
await fs.mkdir(source);
await fs.writeFile(join(source, "Bericht.txt"), "Max Mustermann");
await fs.chmod(join(source, "Bericht.txt"), 0o640);
await fs.writeFile(join(source, "Unberührt.txt"), "bleibt");
const created = await project.createRun(source, support);
await fs.writeFile(join(created.directory, "files", "PERSON_01.txt"), "PERSON_01");
const files: RunFile[] = [{ source: "Bericht.txt", output: "PERSON_01.txt", kind: "text" }];
await project.replaceProjectFilesInPlace(source, created.directory, files);
expect(await fs.readFile(join(source, "PERSON_01.txt"), "utf8")).toBe("PERSON_01");
expect((await fs.stat(join(source, "PERSON_01.txt"))).mode & 0o777).toBe(0o640);
expect(await fs.readFile(join(created.directory, "backup", "Bericht.txt"), "utf8")).toBe("Max Mustermann");
expect((await fs.stat(join(created.directory, "backup", "Bericht.txt"))).mode & 0o777).toBe(0o600);
expect(await fs.readFile(join(source, "Unberührt.txt"), "utf8")).toBe("bleibt");
await expect(fs.access(join(source, "Bericht.txt"))).rejects.toMatchObject({ code: "ENOENT" });
});
it("verhindert Namenskollisionen vor dem Ersetzen", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-in-place-collision-test-"));
roots.push(root);
const source = join(root, "Quelle");
const support = join(root, "Raycast");
await fs.mkdir(source);
await fs.writeFile(join(source, "Bericht.txt"), "Original");
await fs.writeFile(join(source, "PERSON_01.txt"), "Andere Datei");
const created = await project.createRun(source, support);
await fs.writeFile(join(created.directory, "files", "PERSON_01.txt"), "Pseudonym");
await expect(project.replaceProjectFilesInPlace(source, created.directory,
[{ source: "Bericht.txt", output: "PERSON_01.txt", kind: "text" }])).rejects.toThrow("Zielname bereits vorhanden");
expect(await fs.readFile(join(source, "Bericht.txt"), "utf8")).toBe("Original");
expect(await fs.readFile(join(source, "PERSON_01.txt"), "utf8")).toBe("Andere Datei");
});
it("erkennt unveränderte Ausgaben für einen erneuten Lauf desselben Ordners", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-repeat-test-"));
roots.push(root);
const source = join(root, "Quelle");
const support = join(root, "Raycast");
await fs.mkdir(source);
const created = await project.createRun(source, support);
const filename = "PERSON_01.txt";
await fs.writeFile(join(created.directory, "files", filename), "PERSON_01");
const current = join(source, filename);
await fs.writeFile(current, "PERSON_01");
const hash = project.digest(await fs.readFile(current));
expect(await project.matchesPreviousOutput(created.directory, filename, hash)).toBe(true);
await fs.writeFile(current, "PERSON_01 und neuer Text");
expect(await project.matchesPreviousOutput(created.directory, filename,
project.digest(await fs.readFile(current)))).toBe(false);
});
});

View File

@@ -0,0 +1,292 @@
#!/usr/bin/env node
/** One-time, standalone converter. It is not imported by the Raycast extension. */
import { createHash, randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
const styles = ["typed", "title", "lower", "upper", "camel", "pascal", "kebab",
"kebabUpper", "snake", "snakeUpper", "flat", "flatUpper", "camelKeep", "pascalKeep"];
const excluded = new Set([".git", "node_modules", "dist", "build", "out", ".next", ".nuxt",
".venv", "venv", "__pycache__", "target", "vendor", "coverage", ".cache", ".turbo",
".idea", ".vscode", "Pods", "DerivedData"]);
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// In URL-kodierten Dateinamen steht nach %2f unmittelbar ein "f" vor dem Platzhalter.
// Das ist ein Pfadtrenner, kein angrenzender Buchstabe des Bezeichners.
const strictPrefix = "(?:(?<![\\p{L}\\p{N}])|(?<=%2[fF]))";
const hasSeparator = (value) => /[^\p{L}\p{N}]/u.test(value);
const shape = (value) => !/\p{L}/u.test(value) ? "mixed"
: value === value.toUpperCase() ? "upper" : value === value.toLowerCase() ? "lower" : "mixed";
function canonical(pairs, attached) {
const sameShape = pairs.filter((pair) => hasSeparator(pair.termVariant) === hasSeparator(pairs[0].placeholderVariant));
const candidates = sameShape.length ? sameShape : pairs;
const wantedShape = shape(pairs[0].placeholderVariant);
return [...candidates].sort((a, b) => {
const preferred = Number((b.style === "typed") === !attached) - Number((a.style === "typed") === !attached);
if (preferred) return preferred;
const casing = Number(shape(b.termVariant) === wantedShape) - Number(shape(a.termVariant) === wantedShape);
return casing || styles.indexOf(a.style) - styles.indexOf(b.style);
})[0];
}
function category(type) {
return ({ Person: "PERSON", Firma: "FIRMA", Kunde: "KUNDE", System: "BEGRIFF",
Sonstiges: "BEGRIFF" })[type] ?? "BEGRIFF";
}
export function createConverter(manifest, globalTerms = []) {
if (manifest.version !== 1 || !Array.isArray(manifest.variantTable)) {
throw new Error("Unbekanntes Tabellenformat.");
}
const terms = new Map([...globalTerms, ...(manifest.projectTerms ?? [])].map((term) => [term.id, term]));
const groups = new Map();
for (const pair of manifest.variantTable) {
if (typeof pair.placeholderVariant !== "string" || typeof pair.termVariant !== "string"
|| !pair.placeholderVariant || !pair.termVariant || typeof pair.termId !== "string") {
throw new Error("Ungültiger Eintrag in der alten Variante-Tabelle.");
}
const bucket = groups.get(pair.placeholderVariant) ?? [];
bucket.push(pair);
groups.set(pair.placeholderVariant, bucket);
}
const needles = [...groups].map(([needle, pairs]) => ({
needle, pairs, strict: pairs.every((pair) => pair.boundary === "strict"),
})).sort((a, b) => b.needle.length - a.needle.length || a.needle.localeCompare(b.needle));
const lookup = new Map(needles.map((item) => [item.needle, item]));
const pattern = needles.length ? new RegExp(needles.map((item) => item.strict
? `${strictPrefix}${escapeRegex(item.needle)}(?![\\p{L}\\p{N}])`
: escapeRegex(item.needle)).join("|"), "gu") : null;
const mapping = {};
const matchModes = {};
const allocated = new Map();
const counters = new Map();
let replacements = 0;
function convert(input, onReplacement) {
if (!pattern) return input;
pattern.lastIndex = 0;
return input.replace(pattern, (matched, offset, whole) => {
const item = lookup.get(matched);
if (!item) return matched;
const before = offset > 0 ? whole[offset - 1] : "";
const after = whole[offset + matched.length] ?? "";
const attached = /[\p{L}\p{N}_-]/u.test(before) || /[\p{L}\p{N}_-]/u.test(after);
const chosen = canonical(item.pairs, attached);
const type = category(terms.get(chosen.termId)?.category);
const key = `${matched}\0${attached ? "attached" : "standalone"}`;
let placeholder = allocated.get(key);
if (!placeholder) {
// Keep imported identifiers clear of ordinary low-numbered Velum IDs.
let number = counters.get(type) ?? 100000;
do { number += 1; placeholder = `${type}_${String(number).padStart(2, "0")}`; }
while (groups.has(placeholder) || mapping[placeholder]);
counters.set(type, number);
if (Object.keys(mapping).length >= 5000) throw new Error("Mehr als 5.000 neue Platzhalter.");
mapping[placeholder] = { original: chosen.termVariant, type };
matchModes[placeholder] = attached ? "anywhere" : "token";
allocated.set(key, placeholder);
}
replacements += 1;
onReplacement?.(matched, placeholder);
return placeholder;
});
}
return { convert, mapping, matchModes, get replacements() { return replacements; } };
}
function safeRelative(path) {
const value = path.split("\\").join("/");
if (!value || value.startsWith("/") || value.split("/").some((part) => !part || part === "." || part === "..")) {
throw new Error("Ungültiger relativer Pfad.");
}
return value;
}
async function filesUnder(root) {
const files = [];
const skippedFiles = [];
const skip = (path, reason) => skippedFiles.push({ path: relative(root, path).split(sep).join("/"), reason });
async function walk(dir) {
for (const entry of (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
const path = join(dir, entry.name);
if (entry.isSymbolicLink()) { skip(path, "Symbolischer Link"); continue; }
if (entry.isDirectory()) {
if (excluded.has(entry.name)) { skip(path, "Ausgeschlossener Ordner"); continue; }
await walk(path);
} else if (entry.isFile()) {
if (entry.name === ".aishield.json") { skip(path, "Alte Projektmetadaten"); continue; }
if (entry.name === ".env" || entry.name.startsWith(".env.") ||
[".pem", ".key", ".p12", ".pfx"].includes(extname(entry.name).toLowerCase())) {
skip(path, "Vertrauliche Konfigurations- oder Schlüsseldatei");
continue;
}
files.push({ abs: path, rel: relative(root, path).split(sep).join("/") });
}
}
}
await walk(root);
return { files, skippedFiles };
}
function convertedPath(rel, convert) {
return safeRelative(rel.split("/").map(convert).join("/"));
}
function replacementList(counts) {
return [...counts.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
}
function reportText(summary, converted, skippedFiles) {
const quoted = (value) => JSON.stringify(value);
const lines = [
"# Migrationsvorschau für Velum", "",
"Diese Vorschau wurde ohne Übernahme erstellt. Sie enthält alte und neue Platzhalter, aber keine Klartextwerte aus der Zuordnungstabelle.", "",
`Dateien: ${summary.files} · Ausgelassen: ${summary.skipped} · Ersetzungen: ${summary.replacements} · Neue Platzhalter: ${summary.placeholders}`, "",
"## Dateien", "",
];
for (const file of converted) {
lines.push(`- Quelle: ${quoted(file.source)}`);
if (file.source !== file.output) lines.push(` Zielname: ${quoted(file.output)}`);
const pathReplacements = replacementList(file.pathReplacements);
const contentReplacements = replacementList(file.contentReplacements);
if (!pathReplacements.length && !contentReplacements.length) lines.push(" Keine Änderung.");
for (const [label, entries] of [["Name", pathReplacements], ["Inhalt", contentReplacements]]) {
for (const entry of entries) lines.push(` ${label}: ${quoted(entry.from)} → ${quoted(entry.to)} (${entry.count}×)`);
}
}
if (!converted.length) lines.push("Keine übernehmbaren Dateien.");
lines.push("", "## Ausgelassen", "");
for (const item of skippedFiles) lines.push(`- ${quoted(item.path)}: ${item.reason}`);
if (!skippedFiles.length) lines.push("Keine.");
return lines.join("\n") + "\n";
}
export async function migrate({ project, manifestFile, targetSupport, apply = false, previewFile }) {
const root = resolve(project);
if (apply && previewFile) throw new Error("Vorschau-Datei und --apply bitte in getrennten Aufrufen verwenden.");
if (previewFile && (resolve(previewFile) === root || resolve(previewFile).startsWith(root + sep))) {
throw new Error("Die Vorschau-Datei muss außerhalb des Projektordners liegen.");
}
if (previewFile) {
const realRoot = await fs.realpath(root);
const realPreviewParent = await fs.realpath(dirname(resolve(previewFile)));
if (realPreviewParent === realRoot || realPreviewParent.startsWith(realRoot + sep)) {
throw new Error("Die Vorschau-Datei muss außerhalb des Projektordners liegen.");
}
}
if (resolve(targetSupport) === root || resolve(targetSupport).startsWith(root + sep)) {
throw new Error("Der Velum-Datenordner muss außerhalb des Projektordners liegen.");
}
const manifest = JSON.parse(await fs.readFile(resolve(manifestFile), "utf8"));
if (resolve(manifest.projectPath) !== root) throw new Error("Tabelle und Projektordner gehören nicht zusammen.");
let globalTerms = [];
try {
const termFile = join(dirname(dirname(resolve(manifestFile))), "terms.json");
globalTerms = JSON.parse(await fs.readFile(termFile, "utf8")).terms ?? [];
} catch (error) {
if ((error).code !== "ENOENT") throw error;
}
const converter = createConverter(manifest, globalTerms);
const scan = await filesUnder(root);
const converted = [];
const seen = new Set();
const skippedFiles = [...scan.skippedFiles];
const skip = (file, reason) => skippedFiles.push({ path: file.rel, reason });
const track = (counts) => (from, to) => {
const key = `${from}\0${to}`;
const entry = counts.get(key) ?? { from, to, count: 0 };
entry.count += 1;
counts.set(key, entry);
};
for (const file of scan.files) {
if ([".docx", ".xlsx", ".pptx", ".pdf"].includes(extname(file.rel).toLowerCase())) {
skip(file, "Office- oder PDF-Datei");
continue;
}
const bytes = await fs.readFile(file.abs);
if (bytes.includes(0)) { skip(file, "Binärdatei"); continue; }
let text;
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
catch { skip(file, "Kein gültiger UTF-8-Text"); continue; }
const pathReplacements = new Map();
const contentReplacements = new Map();
const output = convertedPath(file.rel, (part) => converter.convert(part, track(pathReplacements)));
if (seen.has(output)) throw new Error("Zwei Dateien hätten denselben Zielnamen.");
seen.add(output);
converted.push({ source: file.rel, output, content: converter.convert(text, track(contentReplacements)),
kind: "text", pathReplacements, contentReplacements });
}
const id = `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
const projectId = createHash("sha1").update(root.replace(/\/+$/, "")).digest("hex").slice(0, 16);
const projectData = join(resolve(targetSupport), "velum-projects", projectId);
const runDir = join(projectData, "runs", id);
const run = {
version: 2, origin: "imported", id, projectPath: root, projectName: basename(root), createdAt: new Date().toISOString(),
mapping: converter.mapping, matchModes: converter.matchModes,
files: converted.map(({ source, output, kind }) => ({ source, output, kind })),
};
const summary = { files: converted.length, skipped: skippedFiles.length, replacements: converter.replacements,
placeholders: Object.keys(converter.mapping).length };
if (!apply) {
if (previewFile) {
await fs.writeFile(resolve(previewFile), reportText(summary, converted, skippedFiles),
{ encoding: "utf8", flag: "wx", mode: 0o600 });
}
return { summary, applied: false, previewFile: previewFile && resolve(previewFile) };
}
const stage = join(projectData, `.${id}.staging`);
await fs.mkdir(join(stage, "files"), { recursive: true, mode: 0o700 });
let committed = false;
try {
for (const file of converted) {
const dest = resolve(stage, "files", file.output);
if (!dest.startsWith(resolve(stage, "files") + sep)) throw new Error("Zielpfad außerhalb des Projektstands.");
await fs.mkdir(dirname(dest), { recursive: true, mode: 0o700 });
await fs.writeFile(dest, file.content, { encoding: "utf8", mode: 0o600 });
}
await fs.writeFile(join(stage, "run.json"), JSON.stringify(run), { encoding: "utf8", mode: 0o600 });
await fs.mkdir(dirname(runDir), { recursive: true, mode: 0o700 });
await fs.rename(stage, runDir);
committed = true;
const projectRules = (manifest.projectTerms ?? []).filter((term) => term.enabled).flatMap((term) => {
const type = category(term.category);
return [term.term, ...(term.aliases ?? []).map((alias) => alias.text)].map((value) => ({ type, value }));
});
const rulesFile = join(projectData, "rules.json");
try { await fs.writeFile(rulesFile, JSON.stringify(projectRules), { encoding: "utf8", flag: "wx", mode: 0o600 }); }
catch (error) { if (error.code !== "EEXIST") throw error; }
} catch (error) {
await fs.rm(stage, { recursive: true, force: true });
if (committed) await fs.rm(runDir, { recursive: true, force: true });
throw error;
}
return { summary, applied: true, runDir };
}
function option(args, name) {
const index = args.indexOf(name);
return index < 0 ? undefined : args[index + 1];
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const args = process.argv.slice(2);
const project = option(args, "--project");
const manifestFile = option(args, "--manifest");
const targetSupport = option(args, "--target-support");
const previewFile = option(args, "--preview-file");
if (!project || !manifestFile || !targetSupport ||
(args.includes("--preview-file") && (!previewFile || previewFile.startsWith("--")))) {
process.stderr.write("Aufruf: node tools/migrate-ki-datenschutz.mjs --project <Ordner> --manifest <alte Tabelle.json> --target-support <Velum-Datenordner> [--preview-file <Bericht.md> | --apply]\n");
process.exitCode = 2;
} else {
try {
const result = await migrate({ project, manifestFile, targetSupport, previewFile, apply: args.includes("--apply") });
process.stdout.write(`${result.applied ? "Import erstellt" : "Vorschau"}: ${result.summary.files} Dateien, ${result.summary.skipped} übersprungen, ${result.summary.replacements} Ersetzungen, ${result.summary.placeholders} neue Platzhalter.\n`);
if (result.previewFile) process.stdout.write(`Bericht: ${result.previewFile}\n`);
} catch (error) {
process.stderr.write(`Migration fehlgeschlagen: ${error.message}\n`);
process.exitCode = 1;
}
}
}