Group repeat pseudonymization runs by project path
This commit is contained in:
@@ -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<boolean> {
|
||||
@@ -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));
|
||||
|
||||
@@ -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<EntityType[]>([]);
|
||||
const [selectedTypes, setSelectedTypes] = useState<EntityType[]>([]);
|
||||
const [whitelist, setWhitelist] = useState<ProjectWhitelistEntry[]>([]);
|
||||
const [existingRuns, setExistingRuns] = useState<number | null>(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 <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.Push title="Projekt-Whitelist bearbeiten" icon={Icon.List}
|
||||
target={<ProjectWhitelist entries={whitelist} onRemove={removeFromWhitelist} />} />
|
||||
@@ -513,6 +540,9 @@ export default function Command() {
|
||||
<Form.FilePicker id="projectPath" title="Projektordner" allowMultipleSelection={false}
|
||||
canChooseDirectories canChooseFiles={false} value={root ? [root] : []}
|
||||
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}
|
||||
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." />
|
||||
|
||||
@@ -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 }) {
|
||||
</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() {
|
||||
const { push } = useNavigation();
|
||||
const [runs, setRuns] = useState<SavedRun[]>([]);
|
||||
@@ -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<string>();
|
||||
return <List isLoading={busy} navigationTitle="Projektstand nachbearbeiten oder rückübersetzen" searchBarPlaceholder="Projekt suchen">
|
||||
const projects = new Map<string, SavedRun[]>();
|
||||
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." />
|
||||
{runs.map(({ run, directory }) => {
|
||||
const isLatest = !latest.has(run.projectPath);
|
||||
latest.add(run.projectPath);
|
||||
return <List.Item key={run.id} icon={Icon.Folder}
|
||||
title={run.projectName} subtitle={run.projectPath}
|
||||
{[...projects].map(([path, entries]) => {
|
||||
const [{ run, directory }] = entries;
|
||||
return <List.Item key={path} icon={Icon.Folder}
|
||||
title={run.projectName} subtitle={path}
|
||||
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>
|
||||
{isLatest && <Action title="Zuordnungen nachbearbeiten" icon={Icon.Pencil}
|
||||
onAction={() => push(<RevisionReview saved={{ run, directory }} onSaved={refresh} />)} />}
|
||||
<Action title="Dateien als Klartext exportieren" icon={Icon.Download}
|
||||
<Action title="Aktuellen Stand nachbearbeiten" icon={Icon.Pencil}
|
||||
onAction={() => push(<RevisionReview saved={{ run, directory }} onSaved={refresh} />)} />
|
||||
<Action title="Aktuelle Dateien als Klartext exportieren" icon={Icon.Download}
|
||||
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>} />;
|
||||
})}
|
||||
</List>;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user