Allow revising saved Velum project mappings
This commit is contained in:
180
src/lib/project-revision.ts
Normal file
180
src/lib/project-revision.ts
Normal 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 };
|
||||
}
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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