Add Velum project workflow and standalone migration tool

This commit is contained in:
muena
2026-09-24 18:14:44 +02:00
parent 2a5c9a66a9
commit c1229e1d14
12 changed files with 1456 additions and 3 deletions

View File

@@ -1,6 +1,29 @@
# 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 einen im Finder ausgewählten Ordner. Ohne Auswahl versucht der Befehl, den geöffneten Finder-Ordner zu übernehmen; andernfalls steht die Ordnerauswahl bereit. Danach werden geeignete Text-, DOCX-, XLSX-, PPTX- und PDF-Dateien ausgewählt. Alle erkannten Treffer einschließlich Datei- und Ordnernamen erscheinen mit Kontext und müssen vor dem Erstellen der Kopien geprüft werden. Personen, Orte und Organisationen sind zunächst abgewählt; explizite Projektbegriffe und strukturierte Treffer sind vorausgewählt. Erst `Kopien erstellen` schreibt Dateien in den Raycast-Support-Bereich außerhalb des Projektordners. 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. Eine neue Projektbearbeitung übernimmt die Zuordnung des letzten Projektstands, sodass Platzhalter konsistent bleiben.
`Projektdateien rückübersetzen` wählt einen Projektstand und einzelne Dateien und erstellt Klartext-Kopien unter `~/Downloads/Velum`. Der Quellordner bleibt unverändert. Die neue 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 rückübersetzen`; 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. Das Skript protokolliert 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. Zuerst ohne `--apply` prüfen:
```bash
node tools/migrate-ki-datenschutz.mjs --project "/Pfad/zum/Projekt" --manifest "/Pfad/zu/projects/abcdef0123456789.json" --target-support "/kopierter/Velum-Datenordner"
```
3. Denselben Befehl 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

View File

@@ -2,7 +2,7 @@
"$schema": "https://www.raycast.com/schemas/extension.json",
"name": "velum",
"title": "Velum",
"description": "Text per Velum-API pseudonymisieren und wiederherstellen.",
"description": "Pseudonymisierung von Texten und Projektdateien mit Velum",
"icon": "extension-icon.png",
"author": "raphael",
"license": "MIT",
@@ -29,6 +29,12 @@
"description": "Aus Notizen, Stichpunkten oder einem Transkript ein strukturiertes Briefing per Raycast-KI erstellen — pseudonymisiert.",
"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",
"title": "Action Items extrahieren",
@@ -88,6 +94,27 @@
"title": "Sitzungen verwalten",
"description": "Velum-Sitzungen mit Zuordnungen anlegen, aktivieren, ansehen und löschen.",
"mode": "view"
},
{
"name": "pseudonymize-project-files",
"title": "Projektdateien pseudonymisieren",
"description": "Finder-Projektordner wählen, Dateien und Treffer prüfen und Kopien erstellen.",
"mode": "view",
"icon": "extension-icon.png"
},
{
"name": "restore-project-files",
"title": "Projektdateien rückübersetzen",
"description": "Ausgewählte Projektdateien als Klartext-Kopien nach Downloads exportieren.",
"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": [
@@ -207,7 +234,8 @@
"dev": "ray develop",
"fix-lint": "ray lint --fix",
"lint": "ray lint",
"publish": "ray publish"
"publish": "ray publish",
"test": "bun test"
},
"dependencies": {
"@raycast/api": "^1.104.17",

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

@@ -0,0 +1,280 @@
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 { environment } from "@raycast/api";
import type { PlaceholderMapping } from "../types";
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[];
};
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"]);
function projectIdFor(path: string): string {
return createHash("sha1").update(path.replace(/\/+$/, "")).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 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"]): RegExp | undefined {
const keys = Object.keys(mapping).sort((a, b) => b.length - a.length);
if (!keys.length) return undefined;
const parts = keys.map((key) => {
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return matchModes?.[key] === "anywhere"
? escaped : `(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`;
});
return new RegExp(parts.join("|"), "gu");
}
export function restoreText(source: string, mapping: PlaceholderMapping, matchModes?: ProjectRun["matchModes"]): string {
const pattern = restorePattern(mapping, matchModes);
return pattern ? source.replace(pattern, (key) => mapping[key].original) : source;
}
export function restorePath(relPath: string, mapping: PlaceholderMapping, matchModes?: ProjectRun["matchModes"]): string {
const pattern = restorePattern(mapping, matchModes);
const segments = safeRelativePath(relPath).split("/").map((segment) => {
const restored = pattern ? segment.replace(pattern, (key) => mapping[key].original) : 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;
}> {
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 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 && run.projectPath === projectPath) runs.push({ run, directory });
} catch { /* incomplete run: not offered for restoration */ }
}
return runs.sort((a, b) => b.run.createdAt.localeCompare(a.run.createdAt));
}
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;
}

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,308 @@
import { Action, ActionPanel, Color, 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, useState } from "react";
import type { PlaceholderMapping } from "./types";
import { pseudonymize, pseudonymizeDocument, requireProjectCapabilities } from "./velum";
import type { DocumentHit } from "./velum";
import {
createRun, digest, forceMasks, listRuns, loadProjectRules, parseProjectRules, replaceSpans,
resolveWithin, safeRelativePath, saveProjectRules, saveRun, scanProjectFiles, toUtf16Offsets,
} from "./lib/velum-project";
import type { ProjectFile, ProjectRule } from "./lib/velum-project";
const run = promisify(execFile);
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[]; paths: PathDraft[]; mapping: PlaceholderMapping;
matchModes?: Record<string, "token" | "anywhere">; hits: Hit[] };
function defaultAccepted(hit: Hit, rules: ProjectRule[]): boolean {
if (rules.some((rule) => rule.type === hit.type && rule.value.toLocaleLowerCase() === hit.original.toLocaleLowerCase())) return true;
return !["PERSON", "ORT", "ORG"].includes(hit.type);
}
async function finderFolder(): Promise<string | undefined> {
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 */ }
try {
const { stdout } = await run("/usr/bin/osascript", ["-e",
'tell application "Finder" to get POSIX path of (target of front Finder window as alias)',
]);
const path = stdout.trim();
return path && (await fs.stat(path)).isDirectory() ? path : undefined;
} catch { 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[]): Promise<Preview> {
await requireProjectCapabilities();
const masks = forceMasks(rules);
const latest = (await listRuns(root))[0]?.run;
if (latest?.origin === "imported") {
throw new Error("Dieser Quellordner enthält bereits pseudonymisierte Dateien. Für einen neuen Lauf bitte einen Klartext-Quellordner wählen.");
}
let mapping: PlaceholderMapping = latest?.mapping ?? {};
const drafts: Draft[] = [];
const hits: Hit[] = [];
for (const file of files) {
const data = await fs.readFile(file.absPath);
let found: Hit[];
if (file.kind === "document") {
const result = await pseudonymizeDocument(basename(file.relPath), data, mapping, masks);
mapping = result.mapping;
found = documentHits(file, result.hits);
} else {
const original = textFromBytes(data);
const result = await pseudonymize(original, mapping, undefined, masks);
mapping = result.mapping;
found = contentHits(file, result.spans, original);
}
drafts.push({ file, hash: digest(data), hits: found });
hits.push(...found);
if (hits.length > 5000) throw new Error("Mehr als 5.000 Treffer. Bitte weniger Dateien auswählen.");
}
const paths = pathSegments(files);
for (const path of paths) {
const result = await pseudonymize(path.value, mapping, undefined, masks);
mapping = result.mapping;
path.hits = toUtf16Offsets(path.value, result.spans).map((span, index) => ({
...span, id: `${path.key}:path:${index}`, file: path.key,
source: "Datei- oder Ordnername", kind: "path" as const,
context: path.value,
}));
hits.push(...path.hits);
if (hits.length > 5000) throw new Error("Mehr als 5.000 Treffer. Bitte weniger Dateien auswählen.");
}
return { drafts, paths, mapping, matchModes: latest?.matchModes, hits };
}
function outputPath(relPath: string, paths: PathDraft[], accepted: Set<string>, document: boolean): 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, path.hits, accepted) + extension;
});
return safeRelativePath(output.join("/"));
}
function FileSelection(props: { root: string; rules: ProjectRule[] }) {
const { push } = useNavigation();
const [files, setFiles] = useState<ProjectFile[]>([]);
const [skipped, setSkipped] = useState<string[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(true);
useEffect(() => {
scanProjectFiles(props.root).then((scan) => {
setFiles(scan.files);
setSkipped(scan.skipped);
setSelected(new Set(scan.files.map((file) => file.relPath)));
setBusy(false);
}).catch((error: Error) => showToast({ style: Toast.Style.Failure, title: "Projekt nicht lesbar", message: error.message }));
}, [props.root]);
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 review() {
const chosen = files.filter((file) => selected.has(file.relPath));
if (!chosen.length) return;
setBusy(true);
const toast = await showToast({ style: Toast.Style.Animated, title: "Treffer werden analysiert" });
try {
const preview = await prepare(props.root, chosen, props.rules);
toast.style = Toast.Style.Success;
toast.title = `${preview.hits.length} Treffer zur Prüfung`;
push(<HitReview root={props.root} rules={props.rules} preview={preview} />);
} catch (error) {
toast.style = Toast.Style.Failure;
toast.title = "Analyse fehlgeschlagen";
toast.message = (error as Error).message;
} finally { setBusy(false); }
}
return <List isLoading={busy} navigationTitle="Dateien auswählen" searchBarPlaceholder="Dateien filtern">
<List.Section title={`${selected.size} von ${files.length} Dateien ausgewählt · ${skipped.length} übersprungen`}>
{files.map((file) => <List.Item key={file.relPath}
icon={selected.has(file.relPath) ? Icon.CheckCircle : Icon.Circle}
title={file.relPath} subtitle={file.kind === "document" ? "Office/PDF" : "Text"}
actions={<ActionPanel>
<Action title={selected.has(file.relPath) ? "Datei abwählen" : "Datei auswählen"} onAction={() => toggle(file.relPath)} />
<Action title="Treffer prüfen" icon={Icon.Eye} onAction={review} />
<Action title="Alle auswählen" onAction={() => setSelected(new Set(files.map((item) => item.relPath)))} />
<Action title="Keine auswählen" onAction={() => setSelected(new Set())} />
</ActionPanel>} />)}
</List.Section>
{skipped.length > 0 && <List.Section title="Übersprungen (binär, zu groß oder ausgeschlossen)">
{skipped.slice(0, 100).map((path) => <List.Item key={path} icon={Icon.XMarkCircle} title={path} />)}
</List.Section>}
</List>;
}
function HitReview(props: { root: string; rules: ProjectRule[]; preview: Preview }) {
const [accepted, setAccepted] = useState<Set<string>>(
new Set(props.preview.hits.filter((hit) => defaultAccepted(hit, props.rules)).map((hit) => hit.id)),
);
const [busy, setBusy] = useState(false);
function toggle(id: string) {
setAccepted((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function apply() {
setBusy(true);
const toast = await showToast({ style: Toast.Style.Animated, title: "Pseudonymisierte Kopien werden erstellt" });
let directory: string | undefined;
try {
const created = await createRun(props.root);
directory = created.directory;
const { run } = created;
run.mapping = props.preview.mapping;
run.matchModes = props.preview.matchModes;
const usedPaths = new Set<string>();
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");
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(props.rules), docIds,
);
output = Buffer.from(result.file_base64, "base64");
} else {
output = Buffer.from(replaceSpans(textFromBytes(data), draft.hits, accepted), "utf8");
}
await fs.writeFile(target, output, { mode: 0o600 });
run.files.push({ source: draft.file.relPath, output: relPath, kind: draft.file.kind });
}
await saveRun(directory, run);
toast.style = Toast.Style.Success;
toast.title = `${run.files.length} Kopien erstellt`;
await showInFinder(join(directory, "files"));
} catch (error) {
if (directory) await fs.rm(directory, { recursive: true, force: true });
toast.style = Toast.Style.Failure;
toast.title = "Pseudonymisierung fehlgeschlagen";
toast.message = (error as Error).message;
} finally { setBusy(false); }
}
const groups = new Map<string, Hit[]>();
for (const hit of props.preview.hits) (groups.get(hit.file) ?? (groups.set(hit.file, []), groups.get(hit.file)!)).push(hit);
return <List isLoading={busy} isShowingDetail navigationTitle="Treffer prüfen"
searchBarPlaceholder="Treffer, Typ oder Datei suchen">
<List.EmptyView title="Keine Treffer" description="Die gewählten Dateien können unverändert kopiert werden."
actions={<ActionPanel><Action title="Kopien erstellen" onAction={apply} /></ActionPanel>} />
{[...groups].map(([file, hits]) => <List.Section key={file} title={file}>
{hits.map((hit) => <List.Item key={hit.id}
icon={{ source: accepted.has(hit.id) ? Icon.CheckCircle : Icon.Circle, tintColor: accepted.has(hit.id) ? Color.Green : Color.SecondaryText }}
title={`${hit.original} → ${hit.placeholder}`}
subtitle={`${hit.type} · ${hit.source}`}
detail={<List.Item.Detail markdown={`**${hit.type}** · ${hit.source}\n\n${hit.context.replace(/</g, "&lt;")}`} />}
actions={<ActionPanel>
<Action title={accepted.has(hit.id) ? "Treffer ausnehmen" : "Treffer übernehmen"} onAction={() => toggle(hit.id)} />
<Action title="Gleichen Begriff übernehmen" onAction={() => setAccepted((current) => new Set([
...current, ...props.preview.hits.filter((other) => other.type === hit.type && other.original === hit.original).map((other) => other.id),
]))} />
<Action title="Kopien erstellen" icon={Icon.Document} onAction={apply} />
</ActionPanel>} />)}
</List.Section>)}
</List>;
}
export default function Command() {
const { push } = useNavigation();
const [root, setRoot] = useState("");
const [rules, setRules] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => { finderFolder().then((path) => { if (path) setRoot(path); }); }, []);
useEffect(() => {
if (!root) return;
loadProjectRules(root).then((found) => setRules(found.map((rule) => `${rule.type}: ${rule.value}`).join("\n")))
.catch(() => setRules(""));
}, [root]);
async function next() {
setBusy(true);
try {
if (!(await fs.stat(root)).isDirectory()) throw new Error("Bitte einen Projektordner wählen.");
const parsed = parseProjectRules(rules);
await saveProjectRules(root, parsed);
push(<FileSelection root={root} rules={parsed} />);
} catch (error) {
await showToast({ style: Toast.Style.Failure, title: "Projekt nicht bereit", message: (error as Error).message });
} finally { setBusy(false); }
}
return <Form isLoading={busy} actions={<ActionPanel><Action title="Dateien auswählen" onAction={next} /></ActionPanel>}>
<Form.FilePicker id="projectPath" title="Projektordner" allowMultipleSelection={false}
canChooseDirectories canChooseFiles={false} value={root ? [root] : []}
onChange={(paths) => setRoot(paths[0] ?? "")} />
<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.Description title="Ablauf" text="Dateien und Treffer auswählen; erst danach werden Kopien außerhalb des Projektordners erstellt." />
</Form>;
}

View File

@@ -0,0 +1,83 @@
import { Action, ActionPanel, 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 { isRestorableDocument, listAllRuns, resolveWithin, restorePath, restoreText } from "./lib/velum-project";
import type { ProjectRun } from "./lib/velum-project";
function FilePicker(props: { run: ProjectRun; directory: string }) {
const [selected, setSelected] = useState<Set<string>>(new Set(props.run.files.map((file) => file.output)));
const [busy, setBusy] = useState(false);
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 (!selected.size) return;
setBusy(true);
const toast = await showToast({ style: Toast.Style.Animated, title: "Klartext-Kopien werden erstellt" });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const targetRoot = join(homedir(), "Downloads", "Velum", `${basename(props.run.projectName)}-${stamp}`);
try {
await fs.mkdir(targetRoot, { recursive: true, mode: 0o700 });
const names = new Set<string>();
for (const file of props.run.files.filter((item) => selected.has(item.output))) {
const restoredName = restorePath(file.output, props.run.mapping, props.run.matchModes);
if (names.has(restoredName)) throw new Error(`Mehrdeutiger Ausgabename: ${restoredName}`);
names.add(restoredName);
const source = resolveWithin(join(props.directory, "files"), file.output);
const target = resolveWithin(targetRoot, restoredName);
const data = await fs.readFile(source);
const result = file.kind === "document" && isRestorableDocument(file.output)
? await depseudonymizeDocument(basename(file.output), data, props.run.mapping)
: Buffer.from(restoreText(data.toString("utf8"), props.run.mapping, props.run.matchModes), "utf8");
await fs.mkdir(dirname(target), { recursive: true, mode: 0o700 });
await fs.writeFile(target, result, { flag: "wx", mode: 0o600 });
}
toast.style = Toast.Style.Success;
toast.title = `${selected.size} Klartext-Dateien in Downloads`;
await showInFinder(targetRoot);
} catch (error) {
toast.style = Toast.Style.Failure;
toast.title = "Klartext-Export fehlgeschlagen";
toast.message = (error as Error).message;
} finally { setBusy(false); }
}
return <List isLoading={busy} navigationTitle={`${props.run.projectName}: Dateien für Klartext wählen`}
searchBarPlaceholder="Dateien filtern">
<List.Section title={`${selected.size} von ${props.run.files.length} Dateien ausgewählt`}>
{props.run.files.map((file) => <List.Item key={file.output}
icon={selected.has(file.output) ? Icon.CheckCircle : Icon.Circle}
title={restorePath(file.output, props.run.mapping, props.run.matchModes)} subtitle={file.output}
actions={<ActionPanel>
<Action title={selected.has(file.output) ? "Datei abwählen" : "Datei auswählen"} onAction={() => toggle(file.output)} />
<Action title="Auswahl nach Downloads rückübersetzen" icon={Icon.Download} onAction={exportFiles} />
<Action title="Alle auswählen" onAction={() => setSelected(new Set(props.run.files.map((item) => item.output)))} />
<Action title="Keine auswählen" onAction={() => setSelected(new Set())} />
</ActionPanel>} />)}
</List.Section>
</List>;
}
export default function Command() {
const { push } = useNavigation();
const [runs, setRuns] = useState<Array<{ run: ProjectRun; directory: string }>>([]);
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));
}, []);
return <List isLoading={busy} navigationTitle="Projektstand rückübersetzen" searchBarPlaceholder="Projekt suchen">
<List.EmptyView title="Noch keine Projektstände" description="Zuerst Dateien mit Velum pseudonymisieren." />
{runs.map(({ run, directory }) => <List.Item key={run.id} icon={Icon.Folder}
title={run.projectName} subtitle={run.projectPath}
accessories={[{ text: `${run.files.length} Dateien` }, { text: new Date(run.createdAt).toLocaleString("de-AT") }]}
actions={<ActionPanel><Action title="Dateien wählen" onAction={() => push(<FilePicker run={run} directory={directory} />)} /></ActionPanel>} />)}
</List>;
}

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 = {
original: string;
type: EntityType;
merged_into?: string | null;
};
export type PlaceholderMapping = Record<string, MappingEntry>;

View File

@@ -108,6 +108,7 @@ export async function pseudonymize(
text: string,
mapping: PlaceholderMapping,
entityTypes?: EntityType[],
forceMasks?: Record<string, string[]>,
): Promise<PseudonymizeResponse> {
const response = await fetchWithAuth(apiUrl("/api/pseudonymize"), {
method: "POST",
@@ -119,12 +120,76 @@ export async function pseudonymize(
mapping,
entity_types:
entityTypes && entityTypes.length > 0 ? entityTypes : undefined,
force_masks: forceMasks,
}),
});
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) {
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[],
): 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 (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(
text: string,
mapping: PlaceholderMapping,

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

@@ -0,0 +1,47 @@
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");
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);
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 });
expect(preview.applied).toBe(false);
expect(preview.summary.files).toBe(1);
expect(await fs.readFile(join(project, legacyName), "utf8")).toBe(legacyContent);
expect(await fs.readdir(root)).not.toContain("VelumSupport");
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);
});

View File

@@ -0,0 +1,47 @@
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 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);
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("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.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");
});
});

View File

@@ -0,0 +1,228 @@
#!/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, "\\$&");
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
? `(?<![\\p{L}\\p{N}])${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) {
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;
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 = [];
let skipped = 0;
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()) { skipped += 1; continue; }
if (entry.isDirectory()) {
if (excluded.has(entry.name)) { skipped += 1; continue; }
await walk(path);
} else if (entry.isFile()) {
if (entry.name === ".aishield.json") continue;
if (entry.name === ".env" || entry.name.startsWith(".env.") ||
[".pem", ".key", ".p12", ".pfx"].includes(extname(entry.name).toLowerCase())) {
skipped += 1;
continue;
}
files.push({ abs: path, rel: relative(root, path).split(sep).join("/") });
}
}
}
await walk(root);
return { files, get skipped() { return skipped; } };
}
function convertedPath(rel, convert) {
return safeRelative(rel.split("/").map(convert).join("/"));
}
export async function migrate({ project, manifestFile, targetSupport, apply = false }) {
const root = resolve(project);
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();
let skipped = scan.skipped;
for (const file of scan.files) {
if ([".docx", ".xlsx", ".pptx", ".pdf"].includes(extname(file.rel).toLowerCase())) {
skipped += 1;
continue;
}
const bytes = await fs.readFile(file.abs);
if (bytes.includes(0)) { skipped += 1; continue; }
let text;
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
catch { skipped += 1; continue; }
const output = convertedPath(file.rel, converter.convert);
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), kind: "text" });
}
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, replacements: converter.replacements,
placeholders: Object.keys(converter.mapping).length };
if (!apply) return { summary, applied: false };
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");
if (!project || !manifestFile || !targetSupport) {
process.stderr.write("Aufruf: node tools/migrate-ki-datenschutz.mjs --project <Ordner> --manifest <alte Tabelle.json> --target-support <Velum-Datenordner> [--apply]\n");
process.exitCode = 2;
} else {
try {
const result = await migrate({ project, manifestFile, targetSupport, 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`);
} catch (error) {
process.stderr.write(`Migration fehlgeschlagen: ${error.message}\n`);
process.exitCode = 1;
}
}
}