Group repeat pseudonymization runs by project path

This commit is contained in:
muena
2026-09-25 16:17:03 +02:00
parent c3ef1a6aed
commit 5877b7cf5f
4 changed files with 91 additions and 15 deletions

View File

@@ -39,8 +39,12 @@ const EXCLUDES = new Set([".git", "node_modules", "dist", "build", "out", ".next
".venv", "venv", "__pycache__", "target", "vendor", "coverage", ".cache", ".turbo", ".venv", "venv", "__pycache__", "target", "vendor", "coverage", ".cache", ".turbo",
".idea", ".vscode", "Pods", "DerivedData"]); ".idea", ".vscode", "Pods", "DerivedData"]);
export function canonicalProjectPath(path: string): string {
return resolve(path);
}
function projectIdFor(path: string): string { 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<boolean> { async function isBinary(path: string): Promise<boolean> {
@@ -245,6 +249,7 @@ export function safeRelativePath(path: string): string {
export async function createRun(projectPath: string, support = environment.supportPath): Promise<{ export async function createRun(projectPath: string, support = environment.supportPath): Promise<{
run: ProjectRun; directory: string; run: ProjectRun; directory: string;
}> { }> {
projectPath = canonicalProjectPath(projectPath);
const id = `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`; const id = `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
const directory = join(projectDataRoot(projectPath, support), "runs", id); const directory = join(projectDataRoot(projectPath, support), "runs", id);
await fs.mkdir(join(directory, "files"), { recursive: true, mode: 0o700 }); 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); const directory = join(root, entry.name);
try { try {
const run = JSON.parse(await fs.readFile(join(directory, "run.json"), "utf8")) as ProjectRun; 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 */ } } catch { /* incomplete run: not offered for restoration */ }
} }
return runs.sort((a, b) => b.run.createdAt.localeCompare(a.run.createdAt)); return runs.sort((a, b) => b.run.createdAt.localeCompare(a.run.createdAt));

View File

@@ -249,6 +249,23 @@ function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: Proje
}); });
} }
async function apply(inPlace = false) { 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({ if (inPlace && !(await confirmAlert({
title: "Originaldateien ersetzen?", 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.", 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<EntityType[]>([]); const [availableTypes, setAvailableTypes] = useState<EntityType[]>([]);
const [selectedTypes, setSelectedTypes] = useState<EntityType[]>([]); const [selectedTypes, setSelectedTypes] = useState<EntityType[]>([]);
const [whitelist, setWhitelist] = useState<ProjectWhitelistEntry[]>([]); const [whitelist, setWhitelist] = useState<ProjectWhitelistEntry[]>([]);
const [existingRuns, setExistingRuns] = useState<number | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
useEffect(() => { finderFolder().then((path) => { if (path) setRoot((current) => current || path); }); }, []); useEffect(() => { finderFolder().then((path) => { if (path) setRoot((current) => current || path); }); }, []);
useEffect(() => { useEffect(() => {
@@ -463,6 +481,15 @@ export default function Command() {
title: "Projekt-Whitelist nicht lesbar", message: error.message })); title: "Projekt-Whitelist nicht lesbar", message: error.message }));
return () => { active = false; }; return () => { active = false; };
}, [root]); }, [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) { async function removeFromWhitelist(entry: ProjectWhitelistEntry) {
try { try {
const next = (await loadProjectWhitelist(root)).filter((item) => whitelistKey(item) !== whitelistKey(entry)); 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." }); message: "Bitte den Ordner im Finder öffnen oder unten manuell auswählen." });
} }
return <Form isLoading={busy} actions={<ActionPanel> return <Form isLoading={busy} actions={<ActionPanel>
<Action title="Zuordnungen prüfen" onAction={next} /> <Action title={existingRuns ? "Bestehendes Projekt erneut prüfen" : "Zuordnungen prüfen"} onAction={next} />
<Action title="Geöffneten Finder-Ordner übernehmen" icon={Icon.Folder} onAction={useFinderFolder} /> <Action title="Geöffneten Finder-Ordner übernehmen" icon={Icon.Folder} onAction={useFinderFolder} />
<Action.Push title="Projekt-Whitelist bearbeiten" icon={Icon.List} <Action.Push title="Projekt-Whitelist bearbeiten" icon={Icon.List}
target={<ProjectWhitelist entries={whitelist} onRemove={removeFromWhitelist} />} /> target={<ProjectWhitelist entries={whitelist} onRemove={removeFromWhitelist} />} />
@@ -513,6 +540,9 @@ export default function Command() {
<Form.FilePicker id="projectPath" title="Projektordner" allowMultipleSelection={false} <Form.FilePicker id="projectPath" title="Projektordner" allowMultipleSelection={false}
canChooseDirectories canChooseFiles={false} value={root ? [root] : []} canChooseDirectories canChooseFiles={false} value={root ? [root] : []}
onChange={(paths) => setRoot(paths[0] ?? "")} /> onChange={(paths) => setRoot(paths[0] ?? "")} />
<Form.Description title="Projektstatus" text={existingRuns === null ? "Projektordner auswählen."
: existingRuns ? `Bereits angelegt · ${existingRuns} gespeicherte Stände. Ein erneuter Lauf aktualisiert dieses Projekt; es wird kein zweites angelegt.`
: "Neues Projekt. Der erste Stand wird nach der Prüfung angelegt."} />
<Form.TextArea id="projectRules" title="Begriffe nur für dieses Projekt" value={rules} <Form.TextArea id="projectRules" title="Begriffe nur für dieses Projekt" value={rules}
onChange={setRules} placeholder={"BEGRIFF: KundenSuite\nPERSON: Max Mustermann"} onChange={setRules} placeholder={"BEGRIFF: KundenSuite\nPERSON: Max Mustermann"}
info="Ein Begriff pro Zeile. Ohne Typ wird BEGRIFF verwendet. Diese Regeln werden nur für dieses Projekt gespeichert und bei der Velum-Anfrage mitgesendet." /> info="Ein Begriff pro Zeile. Ohne Typ wird BEGRIFF verwendet. Diese Regeln werden nur für dieses Projekt gespeichert und bei der Velum-Anfrage mitgesendet." />

View File

@@ -4,7 +4,7 @@ import { homedir } from "node:os";
import { basename, dirname, join } from "node:path"; import { basename, dirname, join } from "node:path";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { depseudonymizeDocument } from "./velum"; 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 type { ProjectRun } from "./lib/velum-project";
import { canonicalPerson, mergePeople } from "./lib/project-review"; import { canonicalPerson, mergePeople } from "./lib/project-review";
import { planProjectRevision, reviseProjectRun } from "./lib/project-revision"; import { planProjectRevision, reviseProjectRun } from "./lib/project-revision";
@@ -189,6 +189,22 @@ function FilePicker(props: { run: ProjectRun; directory: string }) {
</List>; </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="Dateien als Klartext exportieren" icon={Icon.Download}
onAction={() => push(<FilePicker run={run} directory={directory} />)} />
</ActionPanel>} />)}
</List.Section>
</List>;
}
export default function Command() { export default function Command() {
const { push } = useNavigation(); const { push } = useNavigation();
const [runs, setRuns] = useState<SavedRun[]>([]); const [runs, setRuns] = useState<SavedRun[]>([]);
@@ -198,21 +214,27 @@ export default function Command() {
title: "Projektstände nicht lesbar", message: error.message })).finally(() => setBusy(false)); title: "Projektstände nicht lesbar", message: error.message })).finally(() => setBusy(false));
}, []); }, []);
async function refresh() { setRuns(await listAllRuns()); } async function refresh() { setRuns(await listAllRuns()); }
const latest = new Set<string>(); const projects = new Map<string, SavedRun[]>();
return <List isLoading={busy} navigationTitle="Projektstand nachbearbeiten oder rückübersetzen" searchBarPlaceholder="Projekt suchen"> 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." /> <List.EmptyView title="Noch keine Projektstände" description="Zuerst Dateien mit Velum pseudonymisieren." />
{runs.map(({ run, directory }) => { {[...projects].map(([path, entries]) => {
const isLatest = !latest.has(run.projectPath); const [{ run, directory }] = entries;
latest.add(run.projectPath); return <List.Item key={path} icon={Icon.Folder}
return <List.Item key={run.id} icon={Icon.Folder} title={run.projectName} subtitle={path}
title={run.projectName} subtitle={run.projectPath}
accessories={[{ text: `${run.files.length} Dateien` }, { text: new Date(run.createdAt).toLocaleString("de-AT") }, accessories={[{ text: `${run.files.length} Dateien` }, { text: new Date(run.createdAt).toLocaleString("de-AT") },
...(isLatest ? [{ text: "Aktueller Stand" }] : [])]} ...(entries.length > 1 ? [{ text: `${entries.length} Stände` }] : [])]}
actions={<ActionPanel> actions={<ActionPanel>
{isLatest && <Action title="Zuordnungen nachbearbeiten" icon={Icon.Pencil} <Action title="Aktuellen Stand nachbearbeiten" icon={Icon.Pencil}
onAction={() => push(<RevisionReview saved={{ run, directory }} onSaved={refresh} />)} />} onAction={() => push(<RevisionReview saved={{ run, directory }} onSaved={refresh} />)} />
<Action title="Dateien als Klartext exportieren" icon={Icon.Download} <Action title="Aktuelle Dateien als Klartext exportieren" icon={Icon.Download}
onAction={() => push(<FilePicker run={run} directory={directory} />)} /> onAction={() => push(<FilePicker run={run} directory={directory} />)} />
{entries.length > 1 && <Action title="Frühere Stände anzeigen" icon={Icon.Clock}
onAction={() => push(<ProjectHistory projectName={run.projectName} entries={entries.slice(1)} />)} />}
</ActionPanel>} />; </ActionPanel>} />;
})} })}
</List>; </List>;

View File

@@ -36,6 +36,23 @@ describe("Velum-Projektstände", () => {
expect(await fs.readFile(join(source, "Notiz.txt"), "utf8")).toBe("Max Mustermann"); 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", () => { it("ersetzt nur bestätigte Vorkommen und stellt Dateinamen wieder her", () => {
const hits = [ const hits = [
{ id: "a", start: 0, end: 3, placeholder: "PERSON_01" }, { id: "a", start: 0, end: 3, placeholder: "PERSON_01" },