From 5877b7cf5f8d75827c12e6c73087fdf70eb33da7 Mon Sep 17 00:00:00 2001 From: muena Date: Fri, 25 Sep 2026 16:17:03 +0200 Subject: [PATCH] Group repeat pseudonymization runs by project path --- src/lib/velum-project.ts | 11 +++++-- src/pseudonymize-project-files.tsx | 32 ++++++++++++++++++++- src/restore-project-files.tsx | 46 ++++++++++++++++++++++-------- tests/velum-project.test.ts | 17 +++++++++++ 4 files changed, 91 insertions(+), 15 deletions(-) diff --git a/src/lib/velum-project.ts b/src/lib/velum-project.ts index 307d908..58e755f 100644 --- a/src/lib/velum-project.ts +++ b/src/lib/velum-project.ts @@ -39,8 +39,12 @@ const EXCLUDES = new Set([".git", "node_modules", "dist", "build", "out", ".next ".venv", "venv", "__pycache__", "target", "vendor", "coverage", ".cache", ".turbo", ".idea", ".vscode", "Pods", "DerivedData"]); +export function canonicalProjectPath(path: string): string { + return resolve(path); +} + function projectIdFor(path: string): string { - return createHash("sha1").update(path.replace(/\/+$/, "")).digest("hex").slice(0, 16); + return createHash("sha1").update(canonicalProjectPath(path)).digest("hex").slice(0, 16); } async function isBinary(path: string): Promise { @@ -245,6 +249,7 @@ export function safeRelativePath(path: string): string { export async function createRun(projectPath: string, support = environment.supportPath): Promise<{ run: ProjectRun; directory: string; }> { + projectPath = canonicalProjectPath(projectPath); const id = `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`; const directory = join(projectDataRoot(projectPath, support), "runs", id); await fs.mkdir(join(directory, "files"), { recursive: true, mode: 0o700 }); @@ -278,7 +283,9 @@ export async function listRuns(projectPath: string, support = environment.suppor const directory = join(root, entry.name); try { const run = JSON.parse(await fs.readFile(join(directory, "run.json"), "utf8")) as ProjectRun; - if (run.version === 2 && run.projectPath === projectPath) runs.push({ run, directory }); + if (run.version === 2 && canonicalProjectPath(run.projectPath) === canonicalProjectPath(projectPath)) { + runs.push({ run, directory }); + } } catch { /* incomplete run: not offered for restoration */ } } return runs.sort((a, b) => b.run.createdAt.localeCompare(a.run.createdAt)); diff --git a/src/pseudonymize-project-files.tsx b/src/pseudonymize-project-files.tsx index b23a9e6..bcfa178 100644 --- a/src/pseudonymize-project-files.tsx +++ b/src/pseudonymize-project-files.tsx @@ -249,6 +249,23 @@ function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: Proje }); } async function apply(inPlace = false) { + if (!props.preview.drafts.length && !accepted.size) { + try { + const rejected = groups.map((group) => ({ type: group.type, original: group.original })); + if (rejected.length) { + await saveProjectWhitelist(props.root, [...await loadProjectWhitelist(props.root), ...rejected]); + props.onWhitelistChanged(await loadProjectWhitelist(props.root)); + } + await showToast({ style: Toast.Style.Success, title: "Keine neuen Änderungen", + message: rejected.length + ? "Der bestehende Projektstand bleibt erhalten. Abgewählte Begriffe wurden als Ausnahmen gespeichert." + : "Der bestehende Projektstand bleibt erhalten." }); + } catch (error) { + await showToast({ style: Toast.Style.Failure, title: "Ausnahmen konnten nicht gespeichert werden", + message: (error as Error).message }); + } + return; + } 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.", @@ -438,6 +455,7 @@ export default function Command() { const [availableTypes, setAvailableTypes] = useState([]); const [selectedTypes, setSelectedTypes] = useState([]); const [whitelist, setWhitelist] = useState([]); + const [existingRuns, setExistingRuns] = useState(null); const [busy, setBusy] = useState(false); useEffect(() => { finderFolder().then((path) => { if (path) setRoot((current) => current || path); }); }, []); useEffect(() => { @@ -463,6 +481,15 @@ export default function Command() { title: "Projekt-Whitelist nicht lesbar", message: error.message })); return () => { active = false; }; }, [root]); + useEffect(() => { + if (!root) { setExistingRuns(null); return; } + setExistingRuns(null); + let active = true; + listRuns(root).then((found) => { if (active) setExistingRuns(found.length); }) + .catch((error: Error) => showToast({ style: Toast.Style.Failure, + title: "Projektstatus nicht lesbar", message: error.message })); + return () => { active = false; }; + }, [root]); async function removeFromWhitelist(entry: ProjectWhitelistEntry) { try { const next = (await loadProjectWhitelist(root)).filter((item) => whitelistKey(item) !== whitelistKey(entry)); @@ -505,7 +532,7 @@ export default function Command() { message: "Bitte den Ordner im Finder öffnen oder unten manuell auswählen." }); } return
- + } /> @@ -513,6 +540,9 @@ export default function Command() { setRoot(paths[0] ?? "")} /> + diff --git a/src/restore-project-files.tsx b/src/restore-project-files.tsx index c987872..a3a3eec 100644 --- a/src/restore-project-files.tsx +++ b/src/restore-project-files.tsx @@ -4,7 +4,7 @@ import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import { useEffect, useState } from "react"; import { depseudonymizeDocument } from "./velum"; -import { isRestorableDocument, listAllRuns, resolveWithin, restorePath, restoreText } from "./lib/velum-project"; +import { canonicalProjectPath, 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"; @@ -189,6 +189,22 @@ function FilePicker(props: { run: ProjectRun; directory: string }) { ; } +function ProjectHistory(props: { projectName: string; entries: SavedRun[] }) { + const { push } = useNavigation(); + return + + {props.entries.map(({ run, directory }) => + push()} /> + } />)} + + ; +} + export default function Command() { const { push } = useNavigation(); const [runs, setRuns] = useState([]); @@ -198,21 +214,27 @@ export default function Command() { title: "Projektstände nicht lesbar", message: error.message })).finally(() => setBusy(false)); }, []); async function refresh() { setRuns(await listAllRuns()); } - const latest = new Set(); - return + 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 - {runs.map(({ run, directory }) => { - const isLatest = !latest.has(run.projectPath); - latest.add(run.projectPath); - return { + const [{ run, directory }] = entries; + return 1 ? [{ text: `${entries.length} Stände` }] : [])]} actions={ - {isLatest && push()} />} - push()} /> + push()} /> + {entries.length > 1 && push()} />} } />; })} ; diff --git a/tests/velum-project.test.ts b/tests/velum-project.test.ts index 94a22f4..f0e64d9 100644 --- a/tests/velum-project.test.ts +++ b/tests/velum-project.test.ts @@ -36,6 +36,23 @@ describe("Velum-Projektstände", () => { expect(await fs.readFile(join(source, "Notiz.txt"), "utf8")).toBe("Max Mustermann"); }); + it("ordnet erneute Läufe demselben Projektpfad zu und erkennt ältere Pfadschreibweisen", async () => { + const root = await fs.mkdtemp(join(tmpdir(), "velum-project-identity-test-")); + roots.push(root); + const source = join(root, "Quelle"); + const support = join(root, "Raycast"); + await fs.mkdir(source); + const first = await project.createRun(`${source}/`, support); + first.run.projectPath = `${source}/`; + await project.saveRun(first.directory, first.run); + const second = await project.createRun(join(source, "."), support); + await project.saveRun(second.directory, second.run); + expect(project.projectDataRoot(source, support)).toBe(project.projectDataRoot(`${source}/`, support)); + expect(first.run.id).not.toBe(second.run.id); + expect((await project.listRuns(source, support)).map(({ run }) => run.id).sort()) + .toEqual([first.run.id, second.run.id].sort()); + }); + it("ersetzt nur bestätigte Vorkommen und stellt Dateinamen wieder her", () => { const hits = [ { id: "a", start: 0, end: 3, placeholder: "PERSON_01" },