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",
|
".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));
|
||||||
|
|||||||
@@ -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." />
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user