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

180
src/lib/project-revision.ts Normal file
View File

@@ -0,0 +1,180 @@
import { promises as fs } from "node:fs";
import { basename, dirname, join } from "node:path";
import type { PlaceholderMapping } from "../types";
import { canonicalPerson } from "./project-review";
import {
createRun, digest, isRestorableDocument, listRuns, loadProjectWhitelist,
replaceProjectFilesInPlace, resolveWithin, restorePath, restoreText,
saveProjectWhitelist, saveRun,
} from "./velum-project";
import type { ProjectRun, ProjectWhitelistEntry } from "./velum-project";
export type RevisionPlan = {
mapping: PlaceholderMapping;
replacements: PlaceholderMapping;
whitelist: ProjectWhitelistEntry[];
released: string[];
merged: string[];
};
function existingRoot(key: string, mapping: PlaceholderMapping): string {
const seen = new Set<string>();
let current = key;
while (mapping[current]?.merged_into) {
if (seen.has(current)) throw new Error("Zyklische Personen-Zuordnung.");
seen.add(current);
current = mapping[current].merged_into!;
if (!mapping[current] || mapping[current].type !== "PERSON") {
throw new Error("Personen-Zuordnung verweist auf ein ungültiges Ziel.");
}
}
return current;
}
/** Build a single direct substitution for each changed placeholder; no replacement chains. */
export function planProjectRevision(
previous: PlaceholderMapping, selectedRelease: Set<string>, requestedMerges: Record<string, string>,
): RevisionPlan {
for (const key of selectedRelease) if (!previous[key]) throw new Error(`Unbekannter Platzhalter: ${key}`);
const releasedRoots = new Set([...selectedRelease].map((key) => existingRoot(key, previous)));
const released = Object.keys(previous).filter((key) => releasedRoots.has(existingRoot(key, previous)));
const releaseSet = new Set(released);
const roots = new Set(Object.keys(previous).filter((key) =>
previous[key].type === "PERSON" && existingRoot(key, previous) === key && !releaseSet.has(key)));
const merges: Record<string, string> = {};
for (const [source, target] of Object.entries(requestedMerges)) {
if (!roots.has(source) || !roots.has(target) || source === target) {
throw new Error("Zusammenführung benötigt zwei aktive Personen.");
}
merges[source] = target;
}
for (const root of roots) {
const seen = new Set<string>();
let cursor = root;
while (merges[cursor]) {
if (seen.has(cursor)) throw new Error("Zyklische Zusammenführung.");
seen.add(cursor);
cursor = merges[cursor];
}
const final = canonicalPerson(root, merges);
if (!roots.has(final)) throw new Error("Ungültiges Ziel der Zusammenführung.");
}
const mapping: PlaceholderMapping = {};
const replacements: PlaceholderMapping = {};
const merged: string[] = [];
for (const [key, entry] of Object.entries(previous)) {
const oldRoot = entry.type === "PERSON" ? existingRoot(key, previous) : key;
if (releaseSet.has(key)) {
// A canonical placeholder can stand for several spelling variants. Its
// visible text restores to the canonical original; every variant is excluded.
replacements[key] = { original: entry.original, type: entry.type };
continue;
}
const finalRoot = entry.type === "PERSON" ? canonicalPerson(oldRoot, merges) : key;
mapping[key] = { ...entry };
if (entry.type === "PERSON") {
if (finalRoot === key) delete mapping[key].merged_into;
else mapping[key].merged_into = finalRoot;
}
if (finalRoot !== oldRoot) {
replacements[key] = { original: finalRoot, type: entry.type };
merged.push(key);
}
}
// A saved alias may still occur in older output files. Rewrite it together
// with its former canonical root when that root is merged now.
return {
mapping, replacements,
whitelist: released.map((key) => ({ type: previous[key].type, original: previous[key].original })),
released, merged,
};
}
export async function reviseProjectRun(args: {
previous: ProjectRun;
previousDirectory: string;
release: Set<string>;
merges: Record<string, string>;
rewriteDocument: (filename: string, data: Buffer, replacements: PlaceholderMapping) => Promise<Buffer>;
support?: string;
}): Promise<{ run: ProjectRun; directory: string }> {
const { previous, previousDirectory, rewriteDocument, support } = args;
const latest = (await listRuns(previous.projectPath, support))[0];
if (latest?.run.id !== previous.id) {
throw new Error("Es gibt bereits einen neueren Projektstand. Bitte die Liste aktualisieren.");
}
const plan = planProjectRevision(previous.mapping, args.release, args.merges);
if (!plan.released.length && !plan.merged.length) throw new Error("Bitte mindestens einen Begriff freigeben oder Personen zusammenführen.");
const inPlace = Boolean(previous.inPlace || previous.sourceInPlace);
const previousWhitelist = plan.whitelist.length
? await loadProjectWhitelist(previous.projectPath, support) : [];
const created = await createRun(previous.projectPath, support);
const { directory, run } = created;
run.origin = previous.origin;
run.revisedFrom = previous.id;
run.mapping = plan.mapping;
run.matchModes = Object.fromEntries(Object.entries(previous.matchModes ?? {})
.filter(([key]) => key in plan.mapping));
run.sourceInPlace = inPlace;
const usedPaths = new Set<string>();
const hashes = new Map<string, string>();
// "anywhere" placeholders from imported code projects can be prefixes of
// other placeholders. Match unchanged identifiers first as identity entries.
const textReplacements: PlaceholderMapping = Object.fromEntries(Object.entries(previous.mapping)
.filter(([key]) => !(key in plan.replacements))
.map(([key, entry]) => [key, { original: key, type: entry.type }]));
Object.assign(textReplacements, plan.replacements);
let replacementAttempted = false;
let whitelistChanged = false;
try {
for (const file of previous.files) {
const source = resolveWithin(join(previousDirectory, "files"), file.output);
if (!(await fs.lstat(source)).isFile()) throw new Error(`Keine reguläre Datei im Projektstand: ${file.output}`);
const data = await fs.readFile(source);
hashes.set(file.output, digest(data));
const outputPath = restorePath(file.output, textReplacements, previous.matchModes);
if (usedPaths.has(outputPath)) throw new Error(`Doppelter Dateiname nach Änderung: ${outputPath}`);
usedPaths.add(outputPath);
const target = resolveWithin(join(directory, "files"), outputPath);
await fs.mkdir(dirname(target), { recursive: true, mode: 0o700 });
const output = file.kind === "document" && isRestorableDocument(file.output)
? await rewriteDocument(basename(file.output), data, plan.replacements)
: file.kind === "document" ? data
: Buffer.from(restoreText(data.toString("utf8"), textReplacements, previous.matchModes), "utf8");
await fs.writeFile(target, output, { flag: "wx", mode: 0o600 });
run.files.push({ source: file.output, output: outputPath, kind: file.kind });
}
if (plan.whitelist.length) {
await saveProjectWhitelist(previous.projectPath, [...previousWhitelist, ...plan.whitelist], support);
whitelistChanged = true;
}
if (inPlace) {
// The source folder must still be exactly the snapshot being revised.
// This prevents a later local edit from being silently overwritten.
for (const file of previous.files) {
const current = resolveWithin(previous.projectPath, file.output);
if (!(await fs.lstat(current)).isFile() || digest(await fs.readFile(current)) !== hashes.get(file.output)) {
throw new Error(`Projektdatei wurde seit dem letzten Stand geändert: ${file.output}`);
}
}
run.inPlace = true;
await saveRun(directory, run);
replacementAttempted = true;
await replaceProjectFilesInPlace(previous.projectPath, directory, run.files);
} else await saveRun(directory, run);
} catch (error) {
// Keep the backup if replacing the project folder was attempted, even when
// its own rollback reports an error.
if (!replacementAttempted) await fs.rm(directory, { recursive: true, force: true });
else await fs.rm(join(directory, "run.json"), { force: true });
if (whitelistChanged) {
try { await saveProjectWhitelist(previous.projectPath, previousWhitelist, support); }
catch (rollbackError) {
throw new Error(`${(error as Error).message} Die Ausnahmeliste konnte nicht zurückgesetzt werden: ${(rollbackError as Error).message}`);
}
}
if (replacementAttempted) throw new Error(`${(error as Error).message} Sicherung des vorigen Projektstands: ${directory}`);
throw error;
}
return { run, directory };
}

View File

@@ -26,6 +26,8 @@ export type ProjectRun = {
inPlace?: boolean;
/** The project source already contains outputs of an earlier in-place run. */
sourceInPlace?: boolean;
/** Previous immutable snapshot when a project run was revised after creation. */
revisedFrom?: string;
};
const DOCUMENTS = new Set([".docx", ".xlsx", ".pptx", ".pdf"]);

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