diff --git a/README.md b/README.md index 98cc735..8e7d64b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Raycast-Extension für PII-sichere Text- und Projekt-Workflows mit Velum: Inhalt ## Projektdateien -`Projektdateien pseudonymisieren` übernimmt einen im Finder ausgewählten Ordner. Ohne Auswahl versucht der Befehl, den geöffneten Finder-Ordner zu übernehmen; andernfalls steht die Ordnerauswahl bereit. Danach werden geeignete Text-, DOCX-, XLSX-, PPTX- und PDF-Dateien ausgewählt. Alle erkannten Treffer einschließlich Datei- und Ordnernamen erscheinen mit Kontext und müssen vor dem Erstellen der Kopien geprüft werden. Personen, Orte und Organisationen sind zunächst abgewählt; explizite Projektbegriffe und strukturierte Treffer sind vorausgewählt. Erst `Kopien erstellen` schreibt Dateien in den Raycast-Support-Bereich außerhalb des Projektordners. PDF wird dabei als DOCX ausgegeben. +`Projektdateien pseudonymisieren` übernimmt zuerst den geöffneten Finder-Ordner; bei Bedarf kann er im Formular erneut übernommen oder manuell gewählt werden. Danach werden geeignete Text-, DOCX-, XLSX-, PPTX- und PDF-Dateien ausgewählt. Enter führt in der Dateiliste zur Trefferprüfung. Alle erkannten Treffer einschließlich Datei- und Ordnernamen erscheinen mit Kontext und müssen vor dem Abschluss geprüft werden. Personen, Orte und Organisationen sind zunächst abgewählt; explizite Projektbegriffe und strukturierte Treffer sind vorausgewählt. `Kopien erstellen` schreibt Dateien in den Raycast-Support-Bereich außerhalb des Projektordners. Alternativ ersetzt `Originaldateien ersetzen` die gewählten Dateien und gegebenenfalls ihre Namen vor Ort; zuvor legt Velum eine private Sicherung der Originale im Raycast-Support-Bereich an. PDF wird dabei als DOCX ausgegeben. Projektbegriffe werden zeilenweise als `BEGRIFF: KundenSuite` oder `PERSON: Max Mustermann` eingegeben, nur für dieses Projekt gespeichert und mit `force_masks` pro API-Aufruf übertragen. Die globale Velum-Regelliste bleibt unverändert. Eine neue Projektbearbeitung übernimmt die Zuordnung des letzten Projektstands, sodass Platzhalter konsistent bleiben. diff --git a/src/lib/velum-project.ts b/src/lib/velum-project.ts index 1989da2..2bc9dee 100644 --- a/src/lib/velum-project.ts +++ b/src/lib/velum-project.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; -import { promises as fs } from "node:fs"; +import { constants as fsConstants, promises as fs } from "node:fs"; import { basename, dirname, extname, join, relative, resolve, sep } from "node:path"; import { environment } from "@raycast/api"; import type { PlaceholderMapping } from "../types"; @@ -18,6 +18,7 @@ export type ProjectRun = { /** Generic replacement mode for placeholders embedded in code identifiers. */ matchModes?: Record; files: RunFile[]; + inPlace?: boolean; }; const DOCUMENTS = new Set([".docx", ".xlsx", ".pptx", ".pdf"]); @@ -278,3 +279,74 @@ export function resolveWithin(root: string, relPath: string): string { if (!target.startsWith(resolve(root) + sep)) throw new Error("Ausgabepfad verlässt den Zielordner."); return target; } + +async function assertNoSymlinkParents(root: string, relPath: string): Promise { + const parts = safeRelativePath(relPath).split("/"); + let parent = resolve(root); + for (const part of parts.slice(0, -1)) { + parent = join(parent, part); + try { + if ((await fs.lstat(parent)).isSymbolicLink()) throw new Error(`Symbolischer Link im Ausgabepfad: ${relPath}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } +} + +/** Replaces selected originals only after every output exists and a private backup is complete. */ +export async function replaceProjectFilesInPlace(projectPath: string, runDirectory: string, files: RunFile[]): Promise { + const root = resolve(projectPath); + const sourcePaths = new Set(files.map((file) => resolveWithin(root, file.source))); + const targetPaths = new Set(); + const sourceModes = new Map(); + for (const file of files) { + const source = resolveWithin(root, file.source); + const target = resolveWithin(root, file.output); + if (targetPaths.has(target)) throw new Error(`Doppelter Zielname: ${file.output}`); + targetPaths.add(target); + await assertNoSymlinkParents(root, file.output); + const sourceInfo = await fs.lstat(source); + if (!sourceInfo.isFile()) throw new Error(`Quelldatei ist keine reguläre Datei: ${file.source}`); + sourceModes.set(source, sourceInfo.mode & 0o777); + try { + await fs.lstat(target); + if (!sourcePaths.has(target)) throw new Error(`Zielname bereits vorhanden: ${file.output}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await fs.access(resolveWithin(join(runDirectory, "files"), file.output)); + } + + const backupRoot = join(runDirectory, "backup"); + for (const file of files) { + const backup = resolveWithin(backupRoot, file.source); + await fs.mkdir(dirname(backup), { recursive: true, mode: 0o700 }); + await fs.copyFile(resolveWithin(root, file.source), backup, fsConstants.COPYFILE_EXCL); + await fs.chmod(backup, 0o600); + } + + const written: string[] = []; + try { + for (const file of files) await fs.unlink(resolveWithin(root, file.source)); + for (const file of files) { + const target = resolveWithin(root, file.output); + await fs.mkdir(dirname(target), { recursive: true, mode: 0o700 }); + await fs.copyFile(resolveWithin(join(runDirectory, "files"), file.output), target, fsConstants.COPYFILE_EXCL); + written.push(target); + await fs.chmod(target, sourceModes.get(resolveWithin(root, file.source)) ?? 0o600); + } + } catch (error) { + for (const target of written) await fs.rm(target, { force: true }); + for (const file of files) { + const source = resolveWithin(root, file.source); + try { await fs.lstat(source); } + catch (missing) { + if ((missing as NodeJS.ErrnoException).code !== "ENOENT") throw missing; + await fs.mkdir(dirname(source), { recursive: true, mode: 0o700 }); + await fs.copyFile(resolveWithin(backupRoot, file.source), source, fsConstants.COPYFILE_EXCL); + await fs.chmod(source, sourceModes.get(source) ?? 0o600); + } + } + throw new Error(`Ersetzen fehlgeschlagen; Originale wurden wiederhergestellt. Sicherung: ${backupRoot}. ${(error as Error).message}`); + } +} diff --git a/src/pseudonymize-project-files.tsx b/src/pseudonymize-project-files.tsx index b326f7b..27b34e7 100644 --- a/src/pseudonymize-project-files.tsx +++ b/src/pseudonymize-project-files.tsx @@ -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 { + 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 { 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 + {files.map((file) => - toggle(file.relPath)} /> + toggle(file.relPath)} /> setSelected(new Set(files.map((item) => item.relPath)))} /> setSelected(new Set())} /> } />)} @@ -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 } /> + actions={ + apply()} /> + apply(true)} /> + } /> {[...groups].map(([file, hits]) => {hits.map((hit) => } actions={ + apply()} /> + apply(true)} /> toggle(hit.id)} /> setAccepted((current) => new Set([ ...current, ...props.preview.hits.filter((other) => other.type === hit.type && other.original === hit.original).map((other) => other.id), ]))} /> - } />)} )} ; @@ -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
}> + 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 + + + }> setRoot(paths[0] ?? "")} /> diff --git a/tests/velum-project.test.ts b/tests/velum-project.test.ts index 15e661d..9a4c684 100644 --- a/tests/velum-project.test.ts +++ b/tests/velum-project.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, mock } from "bun:test"; import { promises as fs } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { RunFile } from "../src/lib/velum-project"; mock.module("@raycast/api", () => ({ environment: { supportPath: tmpdir() } })); const project = await import("../src/lib/velum-project"); @@ -44,4 +45,41 @@ describe("Velum-Projektstände", () => { const spans = project.toUtf16Offsets(text, [{ id: "max", start: 2, end: 5, placeholder: "PERSON_01" }]); expect(project.replaceSpans(text, spans, new Set(["max"]))).toBe("😀 PERSON_01"); }); + + it("ersetzt ausgewählte Projektdateien vor Ort und sichert die Originale außerhalb", async () => { + const root = await fs.mkdtemp(join(tmpdir(), "velum-in-place-test-")); + roots.push(root); + const source = join(root, "Quelle"); + const support = join(root, "Raycast"); + await fs.mkdir(source); + await fs.writeFile(join(source, "Bericht.txt"), "Max Mustermann"); + await fs.chmod(join(source, "Bericht.txt"), 0o640); + await fs.writeFile(join(source, "Unberührt.txt"), "bleibt"); + const created = await project.createRun(source, support); + await fs.writeFile(join(created.directory, "files", "PERSON_01.txt"), "PERSON_01"); + const files: RunFile[] = [{ source: "Bericht.txt", output: "PERSON_01.txt", kind: "text" }]; + await project.replaceProjectFilesInPlace(source, created.directory, files); + expect(await fs.readFile(join(source, "PERSON_01.txt"), "utf8")).toBe("PERSON_01"); + expect((await fs.stat(join(source, "PERSON_01.txt"))).mode & 0o777).toBe(0o640); + expect(await fs.readFile(join(created.directory, "backup", "Bericht.txt"), "utf8")).toBe("Max Mustermann"); + expect((await fs.stat(join(created.directory, "backup", "Bericht.txt"))).mode & 0o777).toBe(0o600); + expect(await fs.readFile(join(source, "Unberührt.txt"), "utf8")).toBe("bleibt"); + await expect(fs.access(join(source, "Bericht.txt"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("verhindert Namenskollisionen vor dem Ersetzen", async () => { + const root = await fs.mkdtemp(join(tmpdir(), "velum-in-place-collision-test-")); + roots.push(root); + const source = join(root, "Quelle"); + const support = join(root, "Raycast"); + await fs.mkdir(source); + await fs.writeFile(join(source, "Bericht.txt"), "Original"); + await fs.writeFile(join(source, "PERSON_01.txt"), "Andere Datei"); + const created = await project.createRun(source, support); + await fs.writeFile(join(created.directory, "files", "PERSON_01.txt"), "Pseudonym"); + await expect(project.replaceProjectFilesInPlace(source, created.directory, + [{ source: "Bericht.txt", output: "PERSON_01.txt", kind: "text" }])).rejects.toThrow("Zielname bereits vorhanden"); + expect(await fs.readFile(join(source, "Bericht.txt"), "utf8")).toBe("Original"); + expect(await fs.readFile(join(source, "PERSON_01.txt"), "utf8")).toBe("Andere Datei"); + }); });