Allow revising saved Velum project mappings
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user