feat: restore current project copies and choose text mapping

This commit is contained in:
muena
2026-09-27 10:12:46 +02:00
parent ec327a5220
commit 94806aa049
9 changed files with 171 additions and 81 deletions

View File

@@ -4,8 +4,8 @@ import { homedir } from "node:os";
import { basename, dirname, join } from "node:path";
import { useEffect, useState } from "react";
import { depseudonymizeDocument } from "./velum";
import { canonicalProjectPath, isRestorableDocument, listAllRuns, resolveWithin, restorePath, restoreText, runCopiesPath } from "./lib/velum-project";
import type { ProjectRun } from "./lib/velum-project";
import { canonicalProjectPath, listAllRuns, resolveWithin, restorePath, restoreText, runCopiesPath, scanRestorableCopyFiles } from "./lib/velum-project";
import type { ProjectFile, ProjectRun } from "./lib/velum-project";
import { canonicalPerson, mergePeople } from "./lib/project-review";
import { planProjectRevision, reviseProjectRun } from "./lib/project-revision";
@@ -131,9 +131,26 @@ function RevisionReview(props: { saved: SavedRun; onSaved: () => Promise<void> }
</List>;
}
function FilePicker(props: { run: ProjectRun; directory: string }) {
const [selected, setSelected] = useState<Set<string>>(new Set(props.run.files.map((file) => file.output)));
function FilePicker(props: { run: ProjectRun; directory: string; historical?: boolean }) {
const [files, setFiles] = useState<ProjectFile[]>([]);
const [sourceRoot, setSourceRoot] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [skipped, setSkipped] = useState(0);
const [busy, setBusy] = useState(false);
useEffect(() => {
let active = true;
async function load() {
const scan = await scanRestorableCopyFiles(props.run, props.directory, !props.historical);
if (!active) return;
setSourceRoot(scan.root);
setFiles(scan.files);
setSelected(new Set(scan.files.map((file) => file.relPath)));
setSkipped(scan.skipped);
}
load().catch((error: Error) => showToast({ style: Toast.Style.Failure,
title: "Kopienordner nicht lesbar", message: error.message }));
return () => { active = false; };
}, [props.run, props.directory, props.historical]);
function toggle(path: string) {
setSelected((current) => {
const next = new Set(current);
@@ -143,23 +160,30 @@ function FilePicker(props: { run: ProjectRun; directory: string }) {
});
}
async function exportFiles() {
if (!selected.size) return;
if (!selected.size || !sourceRoot) 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}`);
const parent = join(homedir(), "Downloads", "Velum");
let targetRoot: string | undefined;
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);
const planned = files.filter((item) => selected.has(item.relPath)).map((file) => ({
file, restoredName: restorePath(file.relPath, props.run.mapping, props.run.matchModes),
}));
for (const { restoredName } of planned) {
if (names.has(restoredName)) throw new Error(`Mehrdeutiger Ausgabename: ${restoredName}`);
names.add(restoredName);
const source = resolveWithin(join(props.directory, "files"), file.output);
}
await fs.mkdir(parent, { recursive: true, mode: 0o700 });
targetRoot = await fs.mkdtemp(join(parent, `${basename(props.run.projectName)}-${stamp}-`));
for (const { file, restoredName } of planned) {
const source = resolveWithin(sourceRoot, file.relPath);
const target = resolveWithin(targetRoot, restoredName);
if (!(await fs.lstat(source)).isFile()) throw new Error(`Keine reguläre Datei: ${file.relPath}`);
const data = await fs.readFile(source);
const result = file.kind === "document" && isRestorableDocument(file.output)
? await depseudonymizeDocument(basename(file.output), data, props.run.mapping)
const result = file.kind === "document"
? await depseudonymizeDocument(basename(file.relPath), 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 });
@@ -168,21 +192,28 @@ function FilePicker(props: { run: ProjectRun; directory: string }) {
toast.title = `${selected.size} Klartext-Dateien in Downloads`;
await showInFinder(targetRoot);
} catch (error) {
if (targetRoot) await fs.rm(targetRoot, { recursive: true, force: true });
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`}
return <List isLoading={busy || !sourceRoot} 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}
<List.Section title={`${selected.size} von ${files.length} Dateien ausgewählt · ${skipped} übersprungen`}>
<List.Item key="export" icon={Icon.Download} title="Ausgewählte Dateien wiederherstellen"
subtitle="Klartext-Kopien in Downloads/Velum erstellen"
actions={<ActionPanel><Action title="Auswahl nach Downloads wiederherstellen"
icon={Icon.Download} onAction={exportFiles} /></ActionPanel>} />
<List.Item key="source" icon={Icon.Folder} title="Quelle für die Wiederherstellung" subtitle={sourceRoot}
actions={<ActionPanel><Action title="Quellordner im Finder anzeigen" onAction={() => showInFinder(sourceRoot)} /></ActionPanel>} />
{files.map((file) => <List.Item key={file.relPath}
icon={selected.has(file.relPath) ? Icon.CheckCircle : Icon.Circle}
title={restorePath(file.relPath, props.run.mapping, props.run.matchModes)} subtitle={file.relPath}
actions={<ActionPanel>
<Action title={selected.has(file.output) ? "Datei abwählen" : "Datei auswählen"} onAction={() => toggle(file.output)} />
<Action title={selected.has(file.relPath) ? "Datei abwählen" : "Datei auswählen"} onAction={() => toggle(file.relPath)} />
<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="Alle auswählen" onAction={() => setSelected(new Set(files.map((item) => item.relPath)))} />
<Action title="Keine auswählen" onAction={() => setSelected(new Set())} />
</ActionPanel>} />)}
</List.Section>
@@ -201,8 +232,10 @@ function ProjectHistory(props: { projectName: string; entries: SavedRun[] }) {
<Action title="Pseudonymisierte Kopien im Finder anzeigen" icon={Icon.Folder}
onAction={() => showInFinder(runCopiesPath(run, directory))} />
<Action.CopyToClipboard title="Speicherpfad kopieren" content={runCopiesPath(run, directory)} />
<Action title="Dateien als Klartext exportieren" icon={Icon.Download}
onAction={() => push(<FilePicker run={run} directory={directory} />)} />
{run.copyPath && <Action title="Aktuelle Dateien dieses Kopienordners wiederherstellen" icon={Icon.Download}
onAction={() => push(<FilePicker run={run} directory={directory} />)} />}
<Action title="Gespeicherten Stand wiederherstellen" icon={Icon.Clock}
onAction={() => push(<FilePicker run={run} directory={directory} historical />)} />
</ActionPanel>} />)}
</List.Section>
</List>;
@@ -232,13 +265,13 @@ export default function Command() {
accessories={[{ text: `${run.files.length} Dateien` }, { text: new Date(run.createdAt).toLocaleString("de-AT") },
...(entries.length > 1 ? [{ text: `${entries.length} Stände` }] : [])]}
actions={<ActionPanel>
<Action title="Projektdateien als Klartext wiederherstellen" icon={Icon.Download}
onAction={() => push(<FilePicker run={run} directory={directory} />)} />
<Action title="Pseudonymisierte Kopien im Finder anzeigen" icon={Icon.Folder}
onAction={() => showInFinder(runCopiesPath(run, directory))} />
<Action.CopyToClipboard title="Speicherpfad kopieren" content={runCopiesPath(run, directory)} />
<Action title="Aktuellen Stand nachbearbeiten" icon={Icon.Pencil}
onAction={() => push(<RevisionReview saved={{ run, directory }} onSaved={refresh} />)} />
<Action title="Aktuelle Dateien als Klartext exportieren" icon={Icon.Download}
onAction={() => push(<FilePicker run={run} directory={directory} />)} />
{entries.length > 1 && <Action title="Frühere Stände anzeigen" icon={Icon.Clock}
onAction={() => push(<ProjectHistory projectName={run.projectName} entries={entries.slice(1)} />)} />}
</ActionPanel>} />;