317 lines
18 KiB
TypeScript
317 lines
18 KiB
TypeScript
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";
|
|
import { useEffect, useState } from "react";
|
|
import { depseudonymizeDocument } from "./velum";
|
|
import { canonicalProjectPath, listAllRuns, resolveWithin, restorePath, restoreTextWithCount, 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";
|
|
import { TranslationTable } from "./show-translation-tables";
|
|
|
|
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 : runCopiesPath(result.run, result.directory)).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; 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.filter((file) => file.kind === "text").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);
|
|
if (next.has(path)) next.delete(path);
|
|
else next.add(path);
|
|
return next;
|
|
});
|
|
}
|
|
async function exportFiles() {
|
|
if (busy) return;
|
|
if (!selected.size) {
|
|
await showToast({ style: Toast.Style.Failure, title: "Keine Dateien ausgewählt" });
|
|
return;
|
|
}
|
|
if (!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 parent = join(homedir(), "Downloads", "Velum");
|
|
let targetRoot: string | undefined;
|
|
try {
|
|
if (!Object.keys(props.run.mapping).length) throw new Error("Dieser Projektstand hat keine Übersetzungstabelle.");
|
|
const names = new Set<string>();
|
|
const planned = files.filter((item) => selected.has(item.relPath)).map((file) => ({
|
|
file, restoredName: restorePath(file.relPath, props.run.mapping, props.run.matchModes),
|
|
}));
|
|
const renamedFiles = planned.filter(({ file, restoredName }) => restoredName !== file.relPath).length;
|
|
let contentReplacements = 0;
|
|
for (const { restoredName } of planned) {
|
|
if (names.has(restoredName)) throw new Error(`Mehrdeutiger Ausgabename: ${restoredName}`);
|
|
names.add(restoredName);
|
|
}
|
|
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);
|
|
let result: Buffer;
|
|
if (file.kind === "document") {
|
|
result = await depseudonymizeDocument(basename(file.relPath), data, props.run.mapping);
|
|
} else {
|
|
const restored = restoreTextWithCount(data.toString("utf8"), props.run.mapping, props.run.matchModes);
|
|
contentReplacements += restored.replacements;
|
|
result = Buffer.from(restored.text, "utf8");
|
|
}
|
|
await fs.mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
await fs.writeFile(target, result, { flag: "wx", mode: 0o600 });
|
|
}
|
|
if (!renamedFiles && !contentReplacements && planned.every(({ file }) => file.kind === "text")) {
|
|
throw new Error("Keine passenden Velum-Platzhalter in den ausgewählten Textdateien oder Dateinamen gefunden. Bitte Quelle und Projektstand prüfen.");
|
|
}
|
|
toast.style = Toast.Style.Success;
|
|
toast.title = `${selected.size} Klartext-Dateien in Downloads`;
|
|
toast.message = `${contentReplacements} Textersetzungen · ${renamedFiles} Dateinamen geändert`
|
|
+ (planned.some(({ file }) => file.kind === "document") ? " · Office-Inhalte nicht gezählt" : "");
|
|
await showInFinder(targetRoot).catch(() => undefined);
|
|
} 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); }
|
|
}
|
|
const textFiles = files.filter((file) => file.kind === "text");
|
|
const complexFiles = files.filter((file) => file.kind === "document");
|
|
const sourceLabel = props.historical ? "Gespeicherter Projektstand"
|
|
: props.run.inPlace || props.run.sourceInPlace ? "Aktueller Projektordner" : "Pseudonymisierte Kopien";
|
|
const renderFile = (file: ProjectFile) => <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}
|
|
accessories={file.kind === "document" ? [{ text: "Office · manuell auswählen" }] : []}
|
|
actions={<ActionPanel>
|
|
<Action title={selected.has(file.relPath) ? "Datei abwählen" : "Datei auswählen"} onAction={() => toggle(file.relPath)} />
|
|
<Action title="Auswahl nach Downloads wiederherstellen" icon={Icon.Download} onAction={exportFiles} />
|
|
<Action title="Nur Textdateien auswählen" onAction={() => setSelected(new Set(textFiles.map((item) => item.relPath)))} />
|
|
<Action title="Alle Dateien auswählen (inklusive Office)" onAction={() => setSelected(new Set(files.map((item) => item.relPath)))} />
|
|
<Action title="Keine auswählen" onAction={() => setSelected(new Set())} />
|
|
</ActionPanel>} />;
|
|
return <List isLoading={busy || !sourceRoot} navigationTitle={`${props.run.projectName}: Dateien für Klartext wählen`}
|
|
searchBarPlaceholder="Dateien filtern">
|
|
<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 · Office-Dateien nur nach Auswahl"
|
|
actions={<ActionPanel><Action title="Auswahl nach Downloads wiederherstellen"
|
|
icon={Icon.Download} onAction={exportFiles} /></ActionPanel>} />
|
|
<List.Item key="source" icon={Icon.Folder} title={sourceLabel} subtitle={sourceRoot}
|
|
actions={<ActionPanel><Action title="Quellordner im Finder anzeigen" onAction={() => showInFinder(sourceRoot)} /></ActionPanel>} />
|
|
</List.Section>
|
|
<List.Section title={`Textdateien · ${textFiles.length} · standardmäßig ausgewählt`}>
|
|
{textFiles.map(renderFile)}
|
|
</List.Section>
|
|
<List.Section title={`Office-Dateien · ${complexFiles.length} · bewusst auswählen`}>
|
|
{complexFiles.map(renderFile)}
|
|
</List.Section>
|
|
</List>;
|
|
}
|
|
|
|
function ProjectHistory(props: { projectName: string; entries: SavedRun[] }) {
|
|
const { push } = useNavigation();
|
|
return <List navigationTitle={`${props.projectName}: frühere Stände`}
|
|
searchBarPlaceholder="Stand suchen">
|
|
<List.Section title="Frühere Stände · nur für Klartext-Export">
|
|
{props.entries.map(({ run, directory }) => <List.Item key={run.id} icon={Icon.Clock}
|
|
title={new Date(run.createdAt).toLocaleString("de-AT")}
|
|
subtitle={`${run.files.length} Dateien`}
|
|
actions={<ActionPanel>
|
|
<Action title="Gespeicherten Stand wiederherstellen" icon={Icon.Download}
|
|
onAction={() => push(<FilePicker run={run} directory={directory} historical />)} />
|
|
<Action.Push title="Übersetzungstabelle anzeigen" icon={Icon.List}
|
|
target={<TranslationTable title={`${props.projectName} · ${new Date(run.createdAt).toLocaleString("de-AT")}`}
|
|
mapping={run.mapping} />} />
|
|
<Action title="Pseudonymisierte Kopien im Finder anzeigen" icon={Icon.Folder}
|
|
onAction={() => showInFinder(runCopiesPath(run, directory))} />
|
|
<Action.CopyToClipboard title="Speicherpfad kopieren" content={runCopiesPath(run, directory)} />
|
|
{run.copyPath && <Action title="Aktuelle Dateien dieses Kopienordners wiederherstellen" icon={Icon.Download}
|
|
onAction={() => push(<FilePicker run={run} directory={directory} />)} />}
|
|
</ActionPanel>} />)}
|
|
</List.Section>
|
|
</List>;
|
|
}
|
|
|
|
export default function Command() {
|
|
const { push } = useNavigation();
|
|
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));
|
|
}, []);
|
|
async function refresh() { setRuns(await listAllRuns()); }
|
|
const projects = new Map<string, SavedRun[]>();
|
|
for (const entry of runs) {
|
|
const path = canonicalProjectPath(entry.run.projectPath);
|
|
if (!projects.has(path)) projects.set(path, []);
|
|
projects.get(path)!.push(entry);
|
|
}
|
|
return <List isLoading={busy} navigationTitle="Projekte nachbearbeiten oder rückübersetzen" searchBarPlaceholder="Projekt suchen">
|
|
<List.EmptyView title="Noch keine Projektstände" description="Zuerst Dateien mit Velum pseudonymisieren." />
|
|
{[...projects].map(([path, entries]) => {
|
|
const [{ run, directory }] = entries;
|
|
return <List.Item key={path} icon={Icon.Folder}
|
|
title={run.projectName} subtitle={path}
|
|
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.Push title="Übersetzungstabelle anzeigen" icon={Icon.List}
|
|
target={<TranslationTable title={run.projectName} mapping={run.mapping} />} />
|
|
<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} />)} />
|
|
{entries.length > 1 && <Action title="Frühere Stände anzeigen" icon={Icon.Clock}
|
|
onAction={() => push(<ProjectHistory projectName={run.projectName} entries={entries.slice(1)} />)} />}
|
|
</ActionPanel>} />;
|
|
})}
|
|
</List>;
|
|
}
|