Allow revising saved Velum project mappings

This commit is contained in:
muena
2026-09-25 10:31:45 +02:00
parent e7e21e9281
commit d0d6a29ef6
6 changed files with 472 additions and 10 deletions

View File

@@ -1,4 +1,4 @@
import { Action, ActionPanel, Icon, List, showInFinder, showToast, Toast, useNavigation } from "@raycast/api";
import { Action, ActionPanel, Alert, confirmAlert, 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";
@@ -6,6 +6,130 @@ 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";
import { canonicalPerson, mergePeople } from "./lib/project-review";
import { planProjectRevision, reviseProjectRun } from "./lib/project-revision";
type SavedRun = { run: ProjectRun; directory: string };
function RevisionReview(props: { saved: SavedRun; onSaved: () => Promise<void> }) {
const { pop } = useNavigation();
const { run, directory } = props.saved;
const [release, setRelease] = useState<Set<string>>(new Set());
const [merges, setMerges] = useState<Record<string, string>>({});
const [stage, setStage] = useState<"release" | "merge">("release");
const [busy, setBusy] = useState(false);
const entries = Object.entries(run.mapping);
const rootOf = (key: string): string => {
let current = key;
const seen = new Set<string>();
while (run.mapping[current]?.merged_into && !seen.has(current)) {
seen.add(current);
current = run.mapping[current].merged_into!;
}
return current;
};
const roots = entries.filter(([key]) => rootOf(key) === key);
const byType = new Map<string, typeof roots>();
for (const item of roots) {
const type = item[1].type;
if (!byType.has(type)) byType.set(type, []);
byType.get(type)!.push(item);
}
const people = roots.filter(([key, entry]) => entry.type === "PERSON" && !release.has(key));
const variants = (key: string) => entries.filter(([candidate]) => rootOf(candidate) === key)
.map(([, entry]) => entry.original);
function toggle(key: string) {
setRelease((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
setMerges((current) => Object.fromEntries(Object.entries(current)
.filter(([source, target]) => source !== key && target !== key)));
}
async function apply() {
const plan = planProjectRevision(run.mapping, release, merges);
if (!plan.released.length && !plan.merged.length) {
await showToast({ style: Toast.Style.Failure, title: "Keine Änderung ausgewählt" });
return;
}
const inPlace = Boolean(run.inPlace || run.sourceInPlace);
if (inPlace && !(await confirmAlert({
title: "Projektdateien aktualisieren?",
message: `Der vorhandene Projektordner wird mit dem neuen Stand aktualisiert. ${plan.released.length} Zuordnungen werden freigegeben. Vorher wird außerhalb des Projektordners eine Sicherung erstellt. Zwischenzeitlich geänderte Dateien werden nicht überschrieben.`,
primaryAction: { title: "Projekt aktualisieren", style: Alert.ActionStyle.Destructive },
}))) return;
setBusy(true);
const toast = await showToast({ style: Toast.Style.Animated, title: "Neuer Projektstand wird erstellt" });
try {
const result = await reviseProjectRun({ previous: run, previousDirectory: directory,
release, merges, rewriteDocument: depseudonymizeDocument });
await props.onSaved();
toast.style = Toast.Style.Success;
toast.title = "Projektstand aktualisiert";
toast.message = `${plan.released.length} freigegeben · ${plan.merged.length} zusammengeführt`;
pop();
await showInFinder(inPlace ? run.projectPath : join(result.directory, "files")).catch(() => undefined);
} catch (error) {
toast.style = Toast.Style.Failure;
toast.title = "Nachbearbeitung fehlgeschlagen";
toast.message = (error as Error).message;
} finally { setBusy(false); }
}
if (stage === "release") return <List isLoading={busy} navigationTitle="Zuordnungen nachbearbeiten"
searchBarPlaceholder="Begriff, Typ oder Platzhalter suchen">
<List.Section title="Weiter">
<List.Item icon={Icon.ArrowRight} title="Personen zusammenführen und Änderungen anwenden"
subtitle={`${release.size} Begriffe oder Personengruppen freigeben`}
actions={<ActionPanel><Action title="Personen prüfen" onAction={() => setStage("merge")} /></ActionPanel>} />
</List.Section>
{[...byType].map(([type, items]) => <List.Section key={type}
title={`${type} · ${items.filter(([key]) => release.has(key)).length}/${items.length} freigeben`}>
{items.map(([key, entry]) => <List.Item key={key}
icon={release.has(key) ? Icon.CheckCircle : Icon.Circle}
title={entry.original} subtitle={`→ ${key}`}
accessories={[{ text: entry.type }, ...(entry.type === "PERSON" && variants(key).length > 1
? [{ text: `${variants(key).length} Varianten` }] : [])]}
keywords={[key, entry.type, ...variants(key)]}
actions={<ActionPanel>
<Action title={release.has(key) ? "Freigabe zurücknehmen" : "In Klartext umwandeln und ausnehmen"}
onAction={() => toggle(key)} />
<Action title="Personen prüfen" onAction={() => setStage("merge")} />
</ActionPanel>} />)}
</List.Section>)}
</List>;
return <List isLoading={busy} navigationTitle="Personen nachträglich zusammenführen"
searchBarPlaceholder="Person oder Platzhalter suchen">
<List.Section title="Abschluss">
<List.Item icon={Icon.Play} title="Neuen Projektstand erstellen"
subtitle={`${release.size} Freigaben · ${Object.keys(merges).length} Zusammenführungen`}
actions={<ActionPanel>
<Action title="Änderungen anwenden" onAction={apply} />
<Action title="Zur Freigabe zurück" onAction={() => setStage("release")} />
</ActionPanel>} />
</List.Section>
<List.Section title="Personen · optional zusammenfassen">
{people.map(([key]) => {
const target = canonicalPerson(key, merges);
const targets = people.filter(([other]) => canonicalPerson(other, merges) !== target);
return <List.Item key={key} icon={Icon.Person} title={variants(key).join(" · ")}
subtitle={`→ ${target}`}
accessories={target !== key ? [{ text: "Zusammengeführt" }] : []}
actions={<ActionPanel>
{targets.length > 0 && <ActionPanel.Submenu title="Mit Person zusammenfassen" icon={Icon.Link}>
{targets.map(([other]) => <Action key={other}
title={`${variants(other).join(" · ")} → ${canonicalPerson(other, merges)}`}
onAction={() => setMerges((current) => mergePeople(current, key, other))} />)}
</ActionPanel.Submenu>}
{target !== key && <Action title="Zusammenführung lösen" onAction={() => setMerges((current) => {
const next = { ...current }; delete next[key]; return next;
})} />}
<Action title="Änderungen anwenden" onAction={apply} />
</ActionPanel>} />;
})}
</List.Section>
</List>;
}
function FilePicker(props: { run: ProjectRun; directory: string }) {
const [selected, setSelected] = useState<Set<string>>(new Set(props.run.files.map((file) => file.output)));
@@ -67,17 +191,29 @@ function FilePicker(props: { run: ProjectRun; directory: string }) {
export default function Command() {
const { push } = useNavigation();
const [runs, setRuns] = useState<Array<{ run: ProjectRun; directory: string }>>([]);
const [runs, setRuns] = useState<SavedRun[]>([]);
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">
async function refresh() { setRuns(await listAllRuns()); }
const latest = new Set<string>();
return <List isLoading={busy} navigationTitle="Projektstand nachbearbeiten oder 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}
{runs.map(({ run, directory }) => {
const isLatest = !latest.has(run.projectPath);
latest.add(run.projectPath);
return <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>} />)}
accessories={[{ text: `${run.files.length} Dateien` }, { text: new Date(run.createdAt).toLocaleString("de-AT") },
...(isLatest ? [{ text: "Aktueller Stand" }] : [])]}
actions={<ActionPanel>
{isLatest && <Action title="Zuordnungen nachbearbeiten" icon={Icon.Pencil}
onAction={() => push(<RevisionReview saved={{ run, directory }} onSaved={refresh} />)} />}
<Action title="Dateien als Klartext exportieren" icon={Icon.Download}
onAction={() => push(<FilePicker run={run} directory={directory} />)} />
</ActionPanel>} />;
})}
</List>;
}