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

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,