fix: complete Finder project workflow and add in-place output

This commit is contained in:
muena
2026-09-24 19:55:34 +02:00
parent 8e928bc69f
commit 8b254eb87d
4 changed files with 172 additions and 27 deletions

View File

@@ -1,4 +1,4 @@
import { Action, ActionPanel, Color, Form, getSelectedFinderItems, Icon, List, showInFinder, showToast, Toast, useNavigation } from "@raycast/api";
import { Action, ActionPanel, Alert, Color, confirmAlert, Form, getSelectedFinderItems, Icon, List, showInFinder, showToast, Toast, useNavigation } from "@raycast/api";
import { promises as fs } from "node:fs";
import { basename, dirname, extname, join } from "node:path";
import { execFile } from "node:child_process";
@@ -9,7 +9,7 @@ import { pseudonymize, pseudonymizeDocument, requireProjectCapabilities } from "
import type { DocumentHit } from "./velum";
import {
createRun, digest, forceMasks, listRuns, loadProjectRules, parseProjectRules, replaceSpans,
resolveWithin, safeRelativePath, saveProjectRules, saveRun, scanProjectFiles, toUtf16Offsets,
replaceProjectFilesInPlace, resolveWithin, safeRelativePath, saveProjectRules, saveRun, scanProjectFiles, toUtf16Offsets,
} from "./lib/velum-project";
import type { ProjectFile, ProjectRule } from "./lib/velum-project";
@@ -30,6 +30,14 @@ function defaultAccepted(hit: Hit, rules: ProjectRule[]): boolean {
}
async function finderFolder(): Promise<string | undefined> {
try {
// The user asked for the open Finder folder, even when a file inside it is selected.
const { stdout } = await run("/usr/bin/osascript", ["-e",
'tell application "Finder" to get POSIX path of (target of front window as alias)',
]);
const path = stdout.trim();
if (path && (await fs.stat(path)).isDirectory()) return path;
} catch { /* Finder may have no open window or Automation permission */ }
try {
const items = await getSelectedFinderItems();
if (items.length) {
@@ -37,13 +45,7 @@ async function finderFolder(): Promise<string | undefined> {
return (await fs.stat(first)).isDirectory() ? first : dirname(first);
}
} catch { /* Finder selection may be empty or unavailable */ }
try {
const { stdout } = await run("/usr/bin/osascript", ["-e",
'tell application "Finder" to get POSIX path of (target of front Finder window as alias)',
]);
const path = stdout.trim();
return path && (await fs.stat(path)).isDirectory() ? path : undefined;
} catch { return undefined; }
return undefined;
}
function textFromBytes(data: Buffer): string {
@@ -146,8 +148,8 @@ function FileSelection(props: { root: string; rules: ProjectRule[] }) {
setFiles(scan.files);
setSkipped(scan.skipped);
setSelected(new Set(scan.files.map((file) => file.relPath)));
setBusy(false);
}).catch((error: Error) => showToast({ style: Toast.Style.Failure, title: "Projekt nicht lesbar", message: error.message }));
}).catch((error: Error) => showToast({ style: Toast.Style.Failure, title: "Projekt nicht lesbar", message: error.message }))
.finally(() => setBusy(false));
}, [props.root]);
function toggle(path: string) {
@@ -161,7 +163,10 @@ function FileSelection(props: { root: string; rules: ProjectRule[] }) {
async function review() {
const chosen = files.filter((file) => selected.has(file.relPath));
if (!chosen.length) return;
if (!chosen.length) {
await showToast({ style: Toast.Style.Failure, title: "Keine Dateien ausgewählt" });
return;
}
setBusy(true);
const toast = await showToast({ style: Toast.Style.Animated, title: "Treffer werden analysiert" });
try {
@@ -177,13 +182,14 @@ function FileSelection(props: { root: string; rules: ProjectRule[] }) {
}
return <List isLoading={busy} navigationTitle="Dateien auswählen" searchBarPlaceholder="Dateien filtern">
<List.EmptyView title="Keine geeigneten Dateien" description="Bitte einen anderen Projektordner wählen oder die ausgelassenen Dateien prüfen." />
<List.Section title={`${selected.size} von ${files.length} Dateien ausgewählt · ${skipped.length} übersprungen`}>
{files.map((file) => <List.Item key={file.relPath}
icon={selected.has(file.relPath) ? Icon.CheckCircle : Icon.Circle}
title={file.relPath} subtitle={file.kind === "document" ? "Office/PDF" : "Text"}
actions={<ActionPanel>
<Action title={selected.has(file.relPath) ? "Datei abwählen" : "Datei auswählen"} onAction={() => toggle(file.relPath)} />
<Action title="Treffer prüfen" icon={Icon.Eye} onAction={review} />
<Action title={selected.has(file.relPath) ? "Datei abwählen" : "Datei auswählen"} onAction={() => toggle(file.relPath)} />
<Action title="Alle auswählen" onAction={() => setSelected(new Set(files.map((item) => item.relPath)))} />
<Action title="Keine auswählen" onAction={() => setSelected(new Set())} />
</ActionPanel>} />)}
@@ -207,10 +213,17 @@ function HitReview(props: { root: string; rules: ProjectRule[]; preview: Preview
return next;
});
}
async function apply() {
async function apply(inPlace = false) {
if (inPlace && !(await confirmAlert({
title: "Originaldateien ersetzen?",
message: "Die gewählten Dateien und gegebenenfalls ihre Namen werden im Projektordner ersetzt. Die Originale werden vorher außerhalb des Projekts gesichert. PDF-Dateien werden als DOCX ausgegeben.",
primaryAction: { title: "Originaldateien ersetzen", style: Alert.ActionStyle.Destructive },
}))) return;
setBusy(true);
const toast = await showToast({ style: Toast.Style.Animated, title: "Pseudonymisierte Kopien werden erstellt" });
const toast = await showToast({ style: Toast.Style.Animated,
title: inPlace ? "Originaldateien werden ersetzt" : "Pseudonymisierte Kopien werden erstellt" });
let directory: string | undefined;
let persisted = false;
try {
const created = await createRun(props.root);
directory = created.directory;
@@ -240,14 +253,20 @@ function HitReview(props: { root: string; rules: ProjectRule[]; preview: Preview
run.files.push({ source: draft.file.relPath, output: relPath, kind: draft.file.kind });
}
await saveRun(directory, run);
persisted = true;
if (inPlace) {
await replaceProjectFilesInPlace(props.root, directory, run.files);
run.inPlace = true;
await saveRun(directory, run);
}
toast.style = Toast.Style.Success;
toast.title = `${run.files.length} Kopien erstellt`;
await showInFinder(join(directory, "files"));
toast.title = inPlace ? `${run.files.length} Originaldateien ersetzt` : `${run.files.length} Kopien erstellt`;
await showInFinder(inPlace ? props.root : join(directory, "files")).catch(() => undefined);
} catch (error) {
if (directory) await fs.rm(directory, { recursive: true, force: true });
if (directory && !persisted) await fs.rm(directory, { recursive: true, force: true });
toast.style = Toast.Style.Failure;
toast.title = "Pseudonymisierung fehlgeschlagen";
toast.message = (error as Error).message;
toast.message = `${(error as Error).message}${persisted && directory ? ` Kopien/Sicherung: ${directory}` : ""}`;
} finally { setBusy(false); }
}
@@ -256,7 +275,10 @@ function HitReview(props: { root: string; rules: ProjectRule[]; preview: Preview
return <List isLoading={busy} isShowingDetail navigationTitle="Treffer prüfen"
searchBarPlaceholder="Treffer, Typ oder Datei suchen">
<List.EmptyView title="Keine Treffer" description="Die gewählten Dateien können unverändert kopiert werden."
actions={<ActionPanel><Action title="Kopien erstellen" onAction={apply} /></ActionPanel>} />
actions={<ActionPanel>
<Action title="Pseudonymisierte Kopien erstellen" onAction={() => apply()} />
<Action title="Originaldateien ersetzen" onAction={() => apply(true)} />
</ActionPanel>} />
{[...groups].map(([file, hits]) => <List.Section key={file} title={file}>
{hits.map((hit) => <List.Item key={hit.id}
icon={{ source: accepted.has(hit.id) ? Icon.CheckCircle : Icon.Circle, tintColor: accepted.has(hit.id) ? Color.Green : Color.SecondaryText }}
@@ -264,11 +286,12 @@ function HitReview(props: { root: string; rules: ProjectRule[]; preview: Preview
subtitle={`${hit.type} · ${hit.source}`}
detail={<List.Item.Detail markdown={`**${hit.type}** · ${hit.source}\n\n${hit.context.replace(/</g, "&lt;")}`} />}
actions={<ActionPanel>
<Action title="Pseudonymisierte Kopien erstellen" icon={Icon.Document} onAction={() => apply()} />
<Action title="Originaldateien ersetzen" icon={Icon.Pencil} onAction={() => apply(true)} />
<Action title={accepted.has(hit.id) ? "Treffer ausnehmen" : "Treffer übernehmen"} onAction={() => toggle(hit.id)} />
<Action title="Gleichen Begriff übernehmen" onAction={() => setAccepted((current) => new Set([
...current, ...props.preview.hits.filter((other) => other.type === hit.type && other.original === hit.original).map((other) => other.id),
]))} />
<Action title="Kopien erstellen" icon={Icon.Document} onAction={apply} />
</ActionPanel>} />)}
</List.Section>)}
</List>;
@@ -279,11 +302,14 @@ export default function Command() {
const [root, setRoot] = useState("");
const [rules, setRules] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => { finderFolder().then((path) => { if (path) setRoot(path); }); }, []);
useEffect(() => { finderFolder().then((path) => { if (path) setRoot((current) => current || path); }); }, []);
useEffect(() => {
if (!root) return;
loadProjectRules(root).then((found) => setRules(found.map((rule) => `${rule.type}: ${rule.value}`).join("\n")))
.catch(() => setRules(""));
let active = true;
loadProjectRules(root).then((found) => {
if (active) setRules(found.map((rule) => `${rule.type}: ${rule.value}`).join("\n"));
}).catch(() => { if (active) setRules(""); });
return () => { active = false; };
}, [root]);
async function next() {
setBusy(true);
@@ -296,7 +322,16 @@ export default function Command() {
await showToast({ style: Toast.Style.Failure, title: "Projekt nicht bereit", message: (error as Error).message });
} finally { setBusy(false); }
}
return <Form isLoading={busy} actions={<ActionPanel><Action title="Dateien auswählen" onAction={next} /></ActionPanel>}>
async function useFinderFolder() {
const path = await finderFolder();
if (path) setRoot(path);
else await showToast({ style: Toast.Style.Failure, title: "Kein Finder-Ordner gefunden",
message: "Bitte den Ordner im Finder öffnen oder unten manuell auswählen." });
}
return <Form isLoading={busy} actions={<ActionPanel>
<Action title="Dateien auswählen" onAction={next} />
<Action title="Geöffneten Finder-Ordner übernehmen" icon={Icon.Folder} onAction={useFinderFolder} />
</ActionPanel>}>
<Form.FilePicker id="projectPath" title="Projektordner" allowMultipleSelection={false}
canChooseDirectories canChooseFiles={false} value={root ? [root] : []}
onChange={(paths) => setRoot(paths[0] ?? "")} />