Add Velum project workflow and standalone migration tool
This commit is contained in:
25
README.md
25
README.md
@@ -1,6 +1,29 @@
|
|||||||
# Velum Raycast Extension
|
# Velum Raycast Extension
|
||||||
|
|
||||||
Raycast-Extension für PII-sichere Text-Workflows mit Velum: Klarnamen werden lokal in Platzhalter überführt, bevor Text an externe KI-Modelle (Raycast AI), Übersetzer oder andere Tools weitergegeben wird. Originalwerte werden anschließend lokal anhand der Velum-Sitzungs-Zuordnung wiederhergestellt.
|
Raycast-Extension für PII-sichere Text- und Projekt-Workflows mit Velum: Inhalte werden über die konfigurierte Velum-API pseudonymisiert, bevor sie an externe KI-Modelle oder andere Tools weitergegeben werden. Die Velum-API sollte für vertrauliche Kundendaten in einer dafür geeigneten Umgebung betrieben werden.
|
||||||
|
|
||||||
|
## Projektdateien
|
||||||
|
|
||||||
|
`Projektdateien pseudonymisieren` übernimmt einen im Finder ausgewählten Ordner. Ohne Auswahl versucht der Befehl, den geöffneten Finder-Ordner zu übernehmen; andernfalls steht die Ordnerauswahl bereit. Danach werden geeignete Text-, DOCX-, XLSX-, PPTX- und PDF-Dateien ausgewählt. Alle erkannten Treffer einschließlich Datei- und Ordnernamen erscheinen mit Kontext und müssen vor dem Erstellen der Kopien geprüft werden. Personen, Orte und Organisationen sind zunächst abgewählt; explizite Projektbegriffe und strukturierte Treffer sind vorausgewählt. Erst `Kopien erstellen` schreibt Dateien in den Raycast-Support-Bereich außerhalb des Projektordners. PDF wird dabei als DOCX ausgegeben.
|
||||||
|
|
||||||
|
Projektbegriffe werden zeilenweise als `BEGRIFF: KundenSuite` oder `PERSON: Max Mustermann` eingegeben, nur für dieses Projekt gespeichert und mit `force_masks` pro API-Aufruf übertragen. Die globale Velum-Regelliste bleibt unverändert. Eine neue Projektbearbeitung übernimmt die Zuordnung des letzten Projektstands, sodass Platzhalter konsistent bleiben.
|
||||||
|
|
||||||
|
`Projektdateien rückübersetzen` wählt einen Projektstand und einzelne Dateien und erstellt Klartext-Kopien unter `~/Downloads/Velum`. Der Quellordner bleibt unverändert. Die neue Projektfunktion benötigt eine Velum-API mit `/api/project-capabilities`, Trefferprüfung für Dokumente, projektbezogenen `force_masks` und `/api/depseudonymize-document`.
|
||||||
|
|
||||||
|
### Einmalige Übernahme aus KI Datenschutz
|
||||||
|
|
||||||
|
Velum enthält **keinen** Laufzeit-Zugriff auf die frühere Extension. Das eigenständige Skript `tools/migrate-ki-datenschutz.mjs` wandelt einen bereits anonymisierten KI-Datenschutz-Projektordner samt seiner eingefrorenen Variante-Tabelle in einen Velum-Projektstand um. Es verändert weder den alten Ordner noch die alte Tabelle. Der neue Stand erscheint anschließend unter `Projektdateien rückübersetzen`; Begriffe nur für dieses Projekt werden als Velum-Projektregeln übernommen. Alle Inhalte bleiben während der Migration lokal und werden nicht an eine API gesendet. Das Skript protokolliert nur Zahlen, keine Werte oder Dateinamen.
|
||||||
|
|
||||||
|
1. In Raycast `Migrationsziel anzeigen` öffnen und den Velum-Datenordner kopieren.
|
||||||
|
2. Den alten Projektordner und die zugehörige Datei `projects/<Projekt-ID>.json` aus dem Datenordner von KI Datenschutz angeben. Zuerst ohne `--apply` prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tools/migrate-ki-datenschutz.mjs --project "/Pfad/zum/Projekt" --manifest "/Pfad/zu/projects/abcdef0123456789.json" --target-support "/kopierter/Velum-Datenordner"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Denselben Befehl mit `--apply` ausführen. Danach den neuen Stand in Velum öffnen und den Klartext-Export mit einem Testdokument prüfen.
|
||||||
|
|
||||||
|
Das Skript übernimmt Text- und Codedateien. Binäre Dateien und bisher nicht anonymisierte Office/PDF-Dateien werden gezählt und ausgelassen; sie müssen im neuen Projektdateien-Befehl geprüft werden. Platzhalter, die in technischen Bezeichnern kleben, behalten ein eigenes Rückübersetzungsverhalten. Wenn die alte Variante-Tabelle mehrere Klartext-Schreibweisen für denselben Platzhalter enthält, ist die exakte frühere Schreibweise nicht mehr rekonstruierbar; das Skript verwendet dieselbe kontextabhängige Kanonisierung wie der alte Klartext-Export. Die alte Extension kann nach erfolgreicher Prüfung entfernt werden.
|
||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
|
|
||||||
|
|||||||
32
package.json
32
package.json
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://www.raycast.com/schemas/extension.json",
|
"$schema": "https://www.raycast.com/schemas/extension.json",
|
||||||
"name": "velum",
|
"name": "velum",
|
||||||
"title": "Velum",
|
"title": "Velum",
|
||||||
"description": "Text per Velum-API pseudonymisieren und wiederherstellen.",
|
"description": "Pseudonymisierung von Texten und Projektdateien mit Velum",
|
||||||
"icon": "extension-icon.png",
|
"icon": "extension-icon.png",
|
||||||
"author": "raphael",
|
"author": "raphael",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -29,6 +29,12 @@
|
|||||||
"description": "Aus Notizen, Stichpunkten oder einem Transkript ein strukturiertes Briefing per Raycast-KI erstellen — pseudonymisiert.",
|
"description": "Aus Notizen, Stichpunkten oder einem Transkript ein strukturiertes Briefing per Raycast-KI erstellen — pseudonymisiert.",
|
||||||
"mode": "view"
|
"mode": "view"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "summarize-meeting-notes",
|
||||||
|
"title": "Meeting Notes strukturieren",
|
||||||
|
"description": "Aus Meeting-Notizen in Prosa eine sauber strukturierte Zusammenfassung (Kontext, Teilnehmer, Themen, Entscheidungen, Action Items) per Raycast-KI erstellen — pseudonymisiert.",
|
||||||
|
"mode": "view"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "extract-action-items",
|
"name": "extract-action-items",
|
||||||
"title": "Action Items extrahieren",
|
"title": "Action Items extrahieren",
|
||||||
@@ -88,6 +94,27 @@
|
|||||||
"title": "Sitzungen verwalten",
|
"title": "Sitzungen verwalten",
|
||||||
"description": "Velum-Sitzungen mit Zuordnungen anlegen, aktivieren, ansehen und löschen.",
|
"description": "Velum-Sitzungen mit Zuordnungen anlegen, aktivieren, ansehen und löschen.",
|
||||||
"mode": "view"
|
"mode": "view"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pseudonymize-project-files",
|
||||||
|
"title": "Projektdateien pseudonymisieren",
|
||||||
|
"description": "Finder-Projektordner 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": [
|
"preferences": [
|
||||||
@@ -207,7 +234,8 @@
|
|||||||
"dev": "ray develop",
|
"dev": "ray develop",
|
||||||
"fix-lint": "ray lint --fix",
|
"fix-lint": "ray lint --fix",
|
||||||
"lint": "ray lint",
|
"lint": "ray lint",
|
||||||
"publish": "ray publish"
|
"publish": "ray publish",
|
||||||
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@raycast/api": "^1.104.17",
|
"@raycast/api": "^1.104.17",
|
||||||
|
|||||||
280
src/lib/velum-project.ts
Normal file
280
src/lib/velum-project.ts
Normal 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;
|
||||||
|
}
|
||||||
8
src/migration-location.tsx
Normal file
8
src/migration-location.tsx
Normal 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>} />;
|
||||||
|
}
|
||||||
308
src/pseudonymize-project-files.tsx
Normal file
308
src/pseudonymize-project-files.tsx
Normal 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, "<")}`} />}
|
||||||
|
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>;
|
||||||
|
}
|
||||||
83
src/restore-project-files.tsx
Normal file
83
src/restore-project-files.tsx
Normal 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>;
|
||||||
|
}
|
||||||
335
src/summarize-meeting-notes.tsx
Normal file
335
src/summarize-meeting-notes.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export type EntityType = string;
|
|||||||
export type MappingEntry = {
|
export type MappingEntry = {
|
||||||
original: string;
|
original: string;
|
||||||
type: EntityType;
|
type: EntityType;
|
||||||
|
merged_into?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlaceholderMapping = Record<string, MappingEntry>;
|
export type PlaceholderMapping = Record<string, MappingEntry>;
|
||||||
|
|||||||
65
src/velum.ts
65
src/velum.ts
@@ -108,6 +108,7 @@ export async function pseudonymize(
|
|||||||
text: string,
|
text: string,
|
||||||
mapping: PlaceholderMapping,
|
mapping: PlaceholderMapping,
|
||||||
entityTypes?: EntityType[],
|
entityTypes?: EntityType[],
|
||||||
|
forceMasks?: Record<string, string[]>,
|
||||||
): Promise<PseudonymizeResponse> {
|
): Promise<PseudonymizeResponse> {
|
||||||
const response = await fetchWithAuth(apiUrl("/api/pseudonymize"), {
|
const response = await fetchWithAuth(apiUrl("/api/pseudonymize"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -119,12 +120,76 @@ export async function pseudonymize(
|
|||||||
mapping,
|
mapping,
|
||||||
entity_types:
|
entity_types:
|
||||||
entityTypes && entityTypes.length > 0 ? entityTypes : undefined,
|
entityTypes && entityTypes.length > 0 ? entityTypes : undefined,
|
||||||
|
force_masks: forceMasks,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return parseJsonResponse<PseudonymizeResponse>(response);
|
return parseJsonResponse<PseudonymizeResponse>(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DocumentHit = {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
context: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
type: string;
|
||||||
|
score: number;
|
||||||
|
placeholder: string;
|
||||||
|
original: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function requireProjectCapabilities(): Promise<void> {
|
||||||
|
const response = await fetchWithAuth(apiUrl("/api/project-capabilities"));
|
||||||
|
if (!response.ok) throw new Error("Die Velum-API muss für die Projektfunktionen aktualisiert werden.");
|
||||||
|
const capabilities = await parseJsonResponse<Record<string, boolean>>(response);
|
||||||
|
if (!capabilities.request_force_masks || !capabilities.document_hit_review || !capabilities.document_restoration) {
|
||||||
|
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(
|
export async function depseudonymize(
|
||||||
text: string,
|
text: string,
|
||||||
mapping: PlaceholderMapping,
|
mapping: PlaceholderMapping,
|
||||||
|
|||||||
47
tests/migration.test.ts
Normal file
47
tests/migration.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { afterEach, expect, it, mock } from "bun:test";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { migrate } from "../tools/migrate-ki-datenschutz.mjs";
|
||||||
|
|
||||||
|
mock.module("@raycast/api", () => ({ environment: { supportPath: tmpdir() } }));
|
||||||
|
const { restorePath, restoreText } = await import("../src/lib/velum-project");
|
||||||
|
const roots: string[] = [];
|
||||||
|
afterEach(async () => { await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); });
|
||||||
|
|
||||||
|
it("migriert einen künstlichen Altbestand verlustarm ohne den Quellordner zu ändern", async () => {
|
||||||
|
const root = await fs.mkdtemp(join(tmpdir(), "velum-legacy-test-"));
|
||||||
|
roots.push(root);
|
||||||
|
const project = join(root, "Projekt");
|
||||||
|
const targetSupport = join(root, "VelumSupport");
|
||||||
|
const oldSupport = join(root, "OldSupport");
|
||||||
|
await fs.mkdir(project);
|
||||||
|
await fs.mkdir(join(oldSupport, "projects"), { recursive: true });
|
||||||
|
const legacyName = "KUNDE ALPHA.txt";
|
||||||
|
const legacyContent = "kundeAlphaService und KUNDE ALPHA";
|
||||||
|
await fs.writeFile(join(project, legacyName), legacyContent);
|
||||||
|
const projectId = createHash("sha1").update(project).digest("hex").slice(0, 16);
|
||||||
|
const manifestFile = join(oldSupport, "projects", `${projectId}.json`);
|
||||||
|
await fs.writeFile(manifestFile, JSON.stringify({
|
||||||
|
version: 1, projectPath: project, projectTerms: [{ id: "t1", term: "Müller GmbH", category: "Kunde", enabled: true, aliases: [] }],
|
||||||
|
variantTable: [
|
||||||
|
{ termId: "t1", termVariant: "Müller GmbH", placeholderVariant: "KUNDE ALPHA", style: "typed", boundary: "strict" },
|
||||||
|
{ termId: "t1", termVariant: "muellerGmbh", placeholderVariant: "kundeAlpha", style: "camel", boundary: "identifier" },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
const preview = await migrate({ project, manifestFile, targetSupport });
|
||||||
|
expect(preview.applied).toBe(false);
|
||||||
|
expect(preview.summary.files).toBe(1);
|
||||||
|
expect(await fs.readFile(join(project, legacyName), "utf8")).toBe(legacyContent);
|
||||||
|
expect(await fs.readdir(root)).not.toContain("VelumSupport");
|
||||||
|
|
||||||
|
const result = await migrate({ project, manifestFile, targetSupport, apply: true });
|
||||||
|
expect(result.applied).toBe(true);
|
||||||
|
const run = JSON.parse(await fs.readFile(join(result.runDir!, "run.json"), "utf8"));
|
||||||
|
const file = run.files[0];
|
||||||
|
const content = await fs.readFile(join(result.runDir!, "files", file.output), "utf8");
|
||||||
|
expect(restoreText(content, run.mapping, run.matchModes)).toBe("muellerGmbhService und Müller GmbH");
|
||||||
|
expect(restorePath(file.output, run.mapping, run.matchModes)).toBe("Müller GmbH.txt");
|
||||||
|
expect(await fs.readFile(join(project, legacyName), "utf8")).toBe(legacyContent);
|
||||||
|
});
|
||||||
47
tests/velum-project.test.ts
Normal file
47
tests/velum-project.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { afterEach, describe, expect, it, mock } from "bun:test";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
mock.module("@raycast/api", () => ({ environment: { supportPath: tmpdir() } }));
|
||||||
|
const project = await import("../src/lib/velum-project");
|
||||||
|
const roots: string[] = [];
|
||||||
|
afterEach(async () => { await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); });
|
||||||
|
|
||||||
|
describe("Velum-Projektstände", () => {
|
||||||
|
it("speichert Projektregeln und Bearbeitungsstände getrennt vom Quellordner", async () => {
|
||||||
|
const root = await fs.mkdtemp(join(tmpdir(), "velum-project-test-"));
|
||||||
|
roots.push(root);
|
||||||
|
const source = join(root, "Quelle");
|
||||||
|
const support = join(root, "Raycast");
|
||||||
|
await fs.mkdir(source);
|
||||||
|
await fs.writeFile(join(source, "Notiz.txt"), "Max Mustermann");
|
||||||
|
const rules = project.parseProjectRules("PERSON: Max Mustermann\nKundenSuite");
|
||||||
|
await project.saveProjectRules(source, rules, support);
|
||||||
|
expect(await project.loadProjectRules(source, support)).toEqual(rules);
|
||||||
|
const created = await project.createRun(source, support);
|
||||||
|
created.run.mapping = { PERSON_01: { original: "Max Mustermann", type: "PERSON" } };
|
||||||
|
created.run.files.push({ source: "Notiz.txt", output: "PERSON_01.txt", kind: "text" });
|
||||||
|
await project.saveRun(created.directory, created.run);
|
||||||
|
expect((await project.listRuns(source, support))[0].run.files[0].output).toBe("PERSON_01.txt");
|
||||||
|
expect(await fs.readFile(join(source, "Notiz.txt"), "utf8")).toBe("Max Mustermann");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ersetzt nur bestätigte Vorkommen und stellt Dateinamen wieder her", () => {
|
||||||
|
const hits = [
|
||||||
|
{ id: "a", start: 0, end: 3, placeholder: "PERSON_01" },
|
||||||
|
{ id: "b", start: 8, end: 11, placeholder: "PERSON_01" },
|
||||||
|
];
|
||||||
|
expect(project.replaceSpans("Max und Max", hits, new Set(["a"]))).toBe("PERSON_01 und Max");
|
||||||
|
expect(project.restorePath("PERSON_01_Bericht.txt", {
|
||||||
|
PERSON_01: { original: "Max Mustermann", type: "PERSON" },
|
||||||
|
})).toBe("Max Mustermann_Bericht.txt");
|
||||||
|
expect(() => project.safeRelativePath("../geheim.txt")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("übernimmt API-Trefferpositionen auch nach einem Emoji korrekt", () => {
|
||||||
|
const text = "😀 Max";
|
||||||
|
const spans = project.toUtf16Offsets(text, [{ id: "max", start: 2, end: 5, placeholder: "PERSON_01" }]);
|
||||||
|
expect(project.replaceSpans(text, spans, new Set(["max"]))).toBe("😀 PERSON_01");
|
||||||
|
});
|
||||||
|
});
|
||||||
228
tools/migrate-ki-datenschutz.mjs
Normal file
228
tools/migrate-ki-datenschutz.mjs
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/** One-time, standalone converter. It is not imported by the Raycast extension. */
|
||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const styles = ["typed", "title", "lower", "upper", "camel", "pascal", "kebab",
|
||||||
|
"kebabUpper", "snake", "snakeUpper", "flat", "flatUpper", "camelKeep", "pascalKeep"];
|
||||||
|
const excluded = new Set([".git", "node_modules", "dist", "build", "out", ".next", ".nuxt",
|
||||||
|
".venv", "venv", "__pycache__", "target", "vendor", "coverage", ".cache", ".turbo",
|
||||||
|
".idea", ".vscode", "Pods", "DerivedData"]);
|
||||||
|
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const hasSeparator = (value) => /[^\p{L}\p{N}]/u.test(value);
|
||||||
|
const shape = (value) => !/\p{L}/u.test(value) ? "mixed"
|
||||||
|
: value === value.toUpperCase() ? "upper" : value === value.toLowerCase() ? "lower" : "mixed";
|
||||||
|
|
||||||
|
function canonical(pairs, attached) {
|
||||||
|
const sameShape = pairs.filter((pair) => hasSeparator(pair.termVariant) === hasSeparator(pairs[0].placeholderVariant));
|
||||||
|
const candidates = sameShape.length ? sameShape : pairs;
|
||||||
|
const wantedShape = shape(pairs[0].placeholderVariant);
|
||||||
|
return [...candidates].sort((a, b) => {
|
||||||
|
const preferred = Number((b.style === "typed") === !attached) - Number((a.style === "typed") === !attached);
|
||||||
|
if (preferred) return preferred;
|
||||||
|
const casing = Number(shape(b.termVariant) === wantedShape) - Number(shape(a.termVariant) === wantedShape);
|
||||||
|
return casing || styles.indexOf(a.style) - styles.indexOf(b.style);
|
||||||
|
})[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function category(type) {
|
||||||
|
return ({ Person: "PERSON", Firma: "FIRMA", Kunde: "KUNDE", System: "BEGRIFF",
|
||||||
|
Sonstiges: "BEGRIFF" })[type] ?? "BEGRIFF";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConverter(manifest, globalTerms = []) {
|
||||||
|
if (manifest.version !== 1 || !Array.isArray(manifest.variantTable)) {
|
||||||
|
throw new Error("Unbekanntes Tabellenformat.");
|
||||||
|
}
|
||||||
|
const terms = new Map([...globalTerms, ...(manifest.projectTerms ?? [])].map((term) => [term.id, term]));
|
||||||
|
const groups = new Map();
|
||||||
|
for (const pair of manifest.variantTable) {
|
||||||
|
if (typeof pair.placeholderVariant !== "string" || typeof pair.termVariant !== "string"
|
||||||
|
|| !pair.placeholderVariant || !pair.termVariant || typeof pair.termId !== "string") {
|
||||||
|
throw new Error("Ungültiger Eintrag in der alten Variante-Tabelle.");
|
||||||
|
}
|
||||||
|
const bucket = groups.get(pair.placeholderVariant) ?? [];
|
||||||
|
bucket.push(pair);
|
||||||
|
groups.set(pair.placeholderVariant, bucket);
|
||||||
|
}
|
||||||
|
const needles = [...groups].map(([needle, pairs]) => ({
|
||||||
|
needle, pairs, strict: pairs.every((pair) => pair.boundary === "strict"),
|
||||||
|
})).sort((a, b) => b.needle.length - a.needle.length || a.needle.localeCompare(b.needle));
|
||||||
|
const lookup = new Map(needles.map((item) => [item.needle, item]));
|
||||||
|
const pattern = needles.length ? new RegExp(needles.map((item) => item.strict
|
||||||
|
? `(?<![\\p{L}\\p{N}])${escapeRegex(item.needle)}(?![\\p{L}\\p{N}])`
|
||||||
|
: escapeRegex(item.needle)).join("|"), "gu") : null;
|
||||||
|
const mapping = {};
|
||||||
|
const matchModes = {};
|
||||||
|
const allocated = new Map();
|
||||||
|
const counters = new Map();
|
||||||
|
let replacements = 0;
|
||||||
|
|
||||||
|
function convert(input) {
|
||||||
|
if (!pattern) return input;
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
return input.replace(pattern, (matched, offset, whole) => {
|
||||||
|
const item = lookup.get(matched);
|
||||||
|
if (!item) return matched;
|
||||||
|
const before = offset > 0 ? whole[offset - 1] : "";
|
||||||
|
const after = whole[offset + matched.length] ?? "";
|
||||||
|
const attached = /[\p{L}\p{N}_-]/u.test(before) || /[\p{L}\p{N}_-]/u.test(after);
|
||||||
|
const chosen = canonical(item.pairs, attached);
|
||||||
|
const type = category(terms.get(chosen.termId)?.category);
|
||||||
|
const key = `${matched}\0${attached ? "attached" : "standalone"}`;
|
||||||
|
let placeholder = allocated.get(key);
|
||||||
|
if (!placeholder) {
|
||||||
|
// Keep imported identifiers clear of ordinary low-numbered Velum IDs.
|
||||||
|
let number = counters.get(type) ?? 100000;
|
||||||
|
do { number += 1; placeholder = `${type}_${String(number).padStart(2, "0")}`; }
|
||||||
|
while (groups.has(placeholder) || mapping[placeholder]);
|
||||||
|
counters.set(type, number);
|
||||||
|
if (Object.keys(mapping).length >= 5000) throw new Error("Mehr als 5.000 neue Platzhalter.");
|
||||||
|
mapping[placeholder] = { original: chosen.termVariant, type };
|
||||||
|
matchModes[placeholder] = attached ? "anywhere" : "token";
|
||||||
|
allocated.set(key, placeholder);
|
||||||
|
}
|
||||||
|
replacements += 1;
|
||||||
|
return placeholder;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { convert, mapping, matchModes, get replacements() { return replacements; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRelative(path) {
|
||||||
|
const value = path.split("\\").join("/");
|
||||||
|
if (!value || value.startsWith("/") || value.split("/").some((part) => !part || part === "." || part === "..")) {
|
||||||
|
throw new Error("Ungültiger relativer Pfad.");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filesUnder(root) {
|
||||||
|
const files = [];
|
||||||
|
let skipped = 0;
|
||||||
|
async function walk(dir) {
|
||||||
|
for (const entry of (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||||
|
const path = join(dir, entry.name);
|
||||||
|
if (entry.isSymbolicLink()) { skipped += 1; continue; }
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (excluded.has(entry.name)) { skipped += 1; continue; }
|
||||||
|
await walk(path);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
if (entry.name === ".aishield.json") continue;
|
||||||
|
if (entry.name === ".env" || entry.name.startsWith(".env.") ||
|
||||||
|
[".pem", ".key", ".p12", ".pfx"].includes(extname(entry.name).toLowerCase())) {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
files.push({ abs: path, rel: relative(root, path).split(sep).join("/") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await walk(root);
|
||||||
|
return { files, get skipped() { return skipped; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertedPath(rel, convert) {
|
||||||
|
return safeRelative(rel.split("/").map(convert).join("/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrate({ project, manifestFile, targetSupport, apply = false }) {
|
||||||
|
const root = resolve(project);
|
||||||
|
if (resolve(targetSupport) === root || resolve(targetSupport).startsWith(root + sep)) {
|
||||||
|
throw new Error("Der Velum-Datenordner muss außerhalb des Projektordners liegen.");
|
||||||
|
}
|
||||||
|
const manifest = JSON.parse(await fs.readFile(resolve(manifestFile), "utf8"));
|
||||||
|
if (resolve(manifest.projectPath) !== root) throw new Error("Tabelle und Projektordner gehören nicht zusammen.");
|
||||||
|
let globalTerms = [];
|
||||||
|
try {
|
||||||
|
const termFile = join(dirname(dirname(resolve(manifestFile))), "terms.json");
|
||||||
|
globalTerms = JSON.parse(await fs.readFile(termFile, "utf8")).terms ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
if ((error).code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
const converter = createConverter(manifest, globalTerms);
|
||||||
|
const scan = await filesUnder(root);
|
||||||
|
const converted = [];
|
||||||
|
const seen = new Set();
|
||||||
|
let skipped = scan.skipped;
|
||||||
|
for (const file of scan.files) {
|
||||||
|
if ([".docx", ".xlsx", ".pptx", ".pdf"].includes(extname(file.rel).toLowerCase())) {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bytes = await fs.readFile(file.abs);
|
||||||
|
if (bytes.includes(0)) { skipped += 1; continue; }
|
||||||
|
let text;
|
||||||
|
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
|
||||||
|
catch { skipped += 1; continue; }
|
||||||
|
const output = convertedPath(file.rel, converter.convert);
|
||||||
|
if (seen.has(output)) throw new Error("Zwei Dateien hätten denselben Zielnamen.");
|
||||||
|
seen.add(output);
|
||||||
|
converted.push({ source: file.rel, output, content: converter.convert(text), kind: "text" });
|
||||||
|
}
|
||||||
|
const id = `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
|
||||||
|
const projectId = createHash("sha1").update(root.replace(/\/+$/, "")).digest("hex").slice(0, 16);
|
||||||
|
const projectData = join(resolve(targetSupport), "velum-projects", projectId);
|
||||||
|
const runDir = join(projectData, "runs", id);
|
||||||
|
const run = {
|
||||||
|
version: 2, origin: "imported", id, projectPath: root, projectName: basename(root), createdAt: new Date().toISOString(),
|
||||||
|
mapping: converter.mapping, matchModes: converter.matchModes,
|
||||||
|
files: converted.map(({ source, output, kind }) => ({ source, output, kind })),
|
||||||
|
};
|
||||||
|
const summary = { files: converted.length, skipped, replacements: converter.replacements,
|
||||||
|
placeholders: Object.keys(converter.mapping).length };
|
||||||
|
if (!apply) return { summary, applied: false };
|
||||||
|
const stage = join(projectData, `.${id}.staging`);
|
||||||
|
await fs.mkdir(join(stage, "files"), { recursive: true, mode: 0o700 });
|
||||||
|
let committed = false;
|
||||||
|
try {
|
||||||
|
for (const file of converted) {
|
||||||
|
const dest = resolve(stage, "files", file.output);
|
||||||
|
if (!dest.startsWith(resolve(stage, "files") + sep)) throw new Error("Zielpfad außerhalb des Projektstands.");
|
||||||
|
await fs.mkdir(dirname(dest), { recursive: true, mode: 0o700 });
|
||||||
|
await fs.writeFile(dest, file.content, { encoding: "utf8", mode: 0o600 });
|
||||||
|
}
|
||||||
|
await fs.writeFile(join(stage, "run.json"), JSON.stringify(run), { encoding: "utf8", mode: 0o600 });
|
||||||
|
await fs.mkdir(dirname(runDir), { recursive: true, mode: 0o700 });
|
||||||
|
await fs.rename(stage, runDir);
|
||||||
|
committed = true;
|
||||||
|
const projectRules = (manifest.projectTerms ?? []).filter((term) => term.enabled).flatMap((term) => {
|
||||||
|
const type = category(term.category);
|
||||||
|
return [term.term, ...(term.aliases ?? []).map((alias) => alias.text)].map((value) => ({ type, value }));
|
||||||
|
});
|
||||||
|
const rulesFile = join(projectData, "rules.json");
|
||||||
|
try { await fs.writeFile(rulesFile, JSON.stringify(projectRules), { encoding: "utf8", flag: "wx", mode: 0o600 }); }
|
||||||
|
catch (error) { if (error.code !== "EEXIST") throw error; }
|
||||||
|
} catch (error) {
|
||||||
|
await fs.rm(stage, { recursive: true, force: true });
|
||||||
|
if (committed) await fs.rm(runDir, { recursive: true, force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return { summary, applied: true, runDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
function option(args, name) {
|
||||||
|
const index = args.indexOf(name);
|
||||||
|
return index < 0 ? undefined : args[index + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const project = option(args, "--project");
|
||||||
|
const manifestFile = option(args, "--manifest");
|
||||||
|
const targetSupport = option(args, "--target-support");
|
||||||
|
if (!project || !manifestFile || !targetSupport) {
|
||||||
|
process.stderr.write("Aufruf: node tools/migrate-ki-datenschutz.mjs --project <Ordner> --manifest <alte Tabelle.json> --target-support <Velum-Datenordner> [--apply]\n");
|
||||||
|
process.exitCode = 2;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const result = await migrate({ project, manifestFile, targetSupport, apply: args.includes("--apply") });
|
||||||
|
process.stdout.write(`${result.applied ? "Import erstellt" : "Vorschau"}: ${result.summary.files} Dateien, ${result.summary.skipped} übersprungen, ${result.summary.replacements} Ersetzungen, ${result.summary.placeholders} neue Platzhalter.\n`);
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`Migration fehlgeschlagen: ${error.message}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user