From c1229e1d14777a3191dffea9a2af69d1a47ea31e Mon Sep 17 00:00:00 2001 From: muena Date: Thu, 24 Sep 2026 18:14:44 +0200 Subject: [PATCH] Add Velum project workflow and standalone migration tool --- README.md | 25 ++- package.json | 32 ++- src/lib/velum-project.ts | 280 ++++++++++++++++++++++++ src/migration-location.tsx | 8 + src/pseudonymize-project-files.tsx | 308 ++++++++++++++++++++++++++ src/restore-project-files.tsx | 83 +++++++ src/summarize-meeting-notes.tsx | 335 +++++++++++++++++++++++++++++ src/types.ts | 1 + src/velum.ts | 65 ++++++ tests/migration.test.ts | 47 ++++ tests/velum-project.test.ts | 47 ++++ tools/migrate-ki-datenschutz.mjs | 228 ++++++++++++++++++++ 12 files changed, 1456 insertions(+), 3 deletions(-) create mode 100644 src/lib/velum-project.ts create mode 100644 src/migration-location.tsx create mode 100644 src/pseudonymize-project-files.tsx create mode 100644 src/restore-project-files.tsx create mode 100644 src/summarize-meeting-notes.tsx create mode 100644 tests/migration.test.ts create mode 100644 tests/velum-project.test.ts create mode 100644 tools/migrate-ki-datenschutz.mjs diff --git a/README.md b/README.md index 93d55d9..42f92e5 100644 --- a/README.md +++ b/README.md @@ -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/.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 diff --git a/package.json b/package.json index d880918..9a405e0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/velum-project.ts b/src/lib/velum-project.ts new file mode 100644 index 0000000..1989da2 --- /dev/null +++ b/src/lib/velum-project.ts @@ -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; + 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 { + 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 { + 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; + 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 { + 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 { + const result: Record = {}; + 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 { + 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(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 { + 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 : `(? 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 { + 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> { + 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> { + 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; +} diff --git a/src/migration-location.tsx b/src/migration-location.tsx new file mode 100644 index 0000000..665d8b4 --- /dev/null +++ b/src/migration-location.tsx @@ -0,0 +1,8 @@ +import { Action, ActionPanel, Detail, environment } from "@raycast/api"; + +export default function Command() { + return + + } />; +} diff --git a/src/pseudonymize-project-files.tsx b/src/pseudonymize-project-files.tsx new file mode 100644 index 0000000..b326f7b --- /dev/null +++ b/src/pseudonymize-project-files.tsx @@ -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; 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 { + 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(); + 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 { + 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, 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([]); + const [skipped, setSkipped] = useState([]); + const [selected, setSelected] = useState>(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(); + } catch (error) { + toast.style = Toast.Style.Failure; + toast.title = "Analyse fehlgeschlagen"; + toast.message = (error as Error).message; + } finally { setBusy(false); } + } + + return + + {files.map((file) => + toggle(file.relPath)} /> + + setSelected(new Set(files.map((item) => item.relPath)))} /> + setSelected(new Set())} /> + } />)} + + {skipped.length > 0 && + {skipped.slice(0, 100).map((path) => )} + } + ; +} + +function HitReview(props: { root: string; rules: ProjectRule[]; preview: Preview }) { + const [accepted, setAccepted] = useState>( + 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(); + 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(); + for (const hit of props.preview.hits) (groups.get(hit.file) ?? (groups.set(hit.file, []), groups.get(hit.file)!)).push(hit); + return + } /> + {[...groups].map(([file, hits]) => + {hits.map((hit) => } + actions={ + toggle(hit.id)} /> + setAccepted((current) => new Set([ + ...current, ...props.preview.hits.filter((other) => other.type === hit.type && other.original === hit.original).map((other) => other.id), + ]))} /> + + } />)} + )} + ; +} + +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(); + } catch (error) { + await showToast({ style: Toast.Style.Failure, title: "Projekt nicht bereit", message: (error as Error).message }); + } finally { setBusy(false); } + } + return
}> + setRoot(paths[0] ?? "")} /> + + + ; +} diff --git a/src/restore-project-files.tsx b/src/restore-project-files.tsx new file mode 100644 index 0000000..2b69794 --- /dev/null +++ b/src/restore-project-files.tsx @@ -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>(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(); + 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 + + {props.run.files.map((file) => + toggle(file.output)} /> + + setSelected(new Set(props.run.files.map((item) => item.output)))} /> + setSelected(new Set())} /> + } />)} + + ; +} + +export default function Command() { + const { push } = useNavigation(); + const [runs, setRuns] = useState>([]); + 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 + + {runs.map(({ run, directory }) => push()} />} />)} + ; +} diff --git a/src/summarize-meeting-notes.tsx b/src/summarize-meeting-notes.tsx new file mode 100644 index 0000000..240cc51 --- /dev/null +++ b/src/summarize-meeting-notes.tsx @@ -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 , , , 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: `- — Rolle/Funktion`", + "", + "## Themen & Diskussion", + "Pro behandeltem Thema ein eigener Unter-Absatz:", + "### ", + "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. ); 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([]); + const [activeSessionId, setActiveSessionId] = useState(); + const [entityTypes, setEntityTypes] = useState([]); + const [selectedEntityTypes, setSelectedEntityTypes] = useState([]); + const [model, setModel] = useState(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( + , + ); + } catch (error) { + toast.style = Toast.Style.Failure; + toast.title = "Pseudonymisierung fehlgeschlagen"; + toast.message = error instanceof Error ? error.message : String(error); + } + } + + return ( +
+ + + + + } + > + + + + {sessions.map((session) => ( + + ))} + + {entityTypes.length > 0 ? ( + + {entityTypes.map((entityType) => ( + + ))} + + ) : null} + + {MODEL_OPTIONS.map((option) => ( + + ))} + + + {CREATIVITY_OPTIONS.map((value) => ( + + ))} + + + + ); +} diff --git a/src/types.ts b/src/types.ts index 8362e55..8dbcf66 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,7 @@ export type EntityType = string; export type MappingEntry = { original: string; type: EntityType; + merged_into?: string | null; }; export type PlaceholderMapping = Record; diff --git a/src/velum.ts b/src/velum.ts index d8b8681..674ca1c 100644 --- a/src/velum.ts +++ b/src/velum.ts @@ -108,6 +108,7 @@ export async function pseudonymize( text: string, mapping: PlaceholderMapping, entityTypes?: EntityType[], + forceMasks?: Record, ): Promise { 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(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 { + const response = await fetchWithAuth(apiUrl("/api/project-capabilities")); + if (!response.ok) throw new Error("Die Velum-API muss für die Projektfunktionen aktualisiert werden."); + const capabilities = await parseJsonResponse>(response); + if (!capabilities.request_force_masks || !capabilities.document_hit_review || !capabilities.document_restoration) { + 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, + acceptedHitIds?: string[], +): Promise { + 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(response); +} + +export async function depseudonymizeDocument( + filename: string, + data: Buffer, + mapping: PlaceholderMapping, +): Promise { + 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, diff --git a/tests/migration.test.ts b/tests/migration.test.ts new file mode 100644 index 0000000..c0026af --- /dev/null +++ b/tests/migration.test.ts @@ -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); +}); diff --git a/tests/velum-project.test.ts b/tests/velum-project.test.ts new file mode 100644 index 0000000..15e661d --- /dev/null +++ b/tests/velum-project.test.ts @@ -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"); + }); +}); diff --git a/tools/migrate-ki-datenschutz.mjs b/tools/migrate-ki-datenschutz.mjs new file mode 100644 index 0000000..0fa8cf8 --- /dev/null +++ b/tools/migrate-ki-datenschutz.mjs @@ -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 + ? `(? { + 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 --manifest --target-support [--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; + } + } +}