Add Velum project workflow and standalone migration tool

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

View File

@@ -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>;
}