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 }) { const { pop } = useNavigation(); const { run, directory } = props.saved; const [release, setRelease] = useState>(new Set()); const [merges, setMerges] = useState>({}); 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(); 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(); 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 setStage("merge")} />} /> {[...byType].map(([type, items]) => release.has(key)).length}/${items.length} freigeben`}> {items.map(([key, entry]) => 1 ? [{ text: `${variants(key).length} Varianten` }] : [])]} keywords={[key, entry.type, ...variants(key)]} actions={ toggle(key)} /> setStage("merge")} /> } />)} )} ; return setStage("release")} /> } /> {people.map(([key]) => { const target = canonicalPerson(key, merges); const targets = people.filter(([other]) => canonicalPerson(other, merges) !== target); return {targets.length > 0 && {targets.map(([other]) => setMerges((current) => mergePeople(current, key, other))} />)} } {target !== key && setMerges((current) => { const next = { ...current }; delete next[key]; return next; })} />} } />; })} ; } function FilePicker(props: { run: ProjectRun; directory: string; historical?: boolean }) { const [files, setFiles] = useState([]); const [sourceRoot, setSourceRoot] = useState(""); const [selected, setSelected] = useState>(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(); 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) => toggle(file.relPath)} /> setSelected(new Set(textFiles.map((item) => item.relPath)))} /> setSelected(new Set(files.map((item) => item.relPath)))} /> setSelected(new Set())} /> } />; return } /> showInFinder(sourceRoot)} />} /> {textFiles.map(renderFile)} {complexFiles.map(renderFile)} ; } function ProjectHistory(props: { projectName: string; entries: SavedRun[] }) { const { push } = useNavigation(); return {props.entries.map(({ run, directory }) => push()} /> } /> showInFinder(runCopiesPath(run, directory))} /> {run.copyPath && push()} />} } />)} ; } export default function Command() { const { push } = useNavigation(); const [runs, setRuns] = useState([]); 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(); for (const entry of runs) { const path = canonicalProjectPath(entry.run.projectPath); if (!projects.has(path)) projects.set(path, []); projects.get(path)!.push(entry); } return {[...projects].map(([path, entries]) => { const [{ run, directory }] = entries; return 1 ? [{ text: `${entries.length} Stände` }] : [])]} actions={ push()} /> } /> showInFinder(runCopiesPath(run, directory))} /> push()} /> {entries.length > 1 && push()} />} } />; })} ; }