feat: publish Velum copies beside project folder

This commit is contained in:
muena
2026-09-27 08:58:40 +02:00
parent 23af4993db
commit ec327a5220
6 changed files with 95 additions and 21 deletions

View File

@@ -3,7 +3,7 @@ import { basename, dirname, join } from "node:path";
import type { PlaceholderMapping } from "../types"; import type { PlaceholderMapping } from "../types";
import { canonicalPerson } from "./project-review"; import { canonicalPerson } from "./project-review";
import { import {
createRun, digest, isRestorableDocument, listRuns, loadProjectWhitelist, createRun, digest, isRestorableDocument, listRuns, loadProjectWhitelist, publishProjectCopies,
replaceProjectFilesInPlace, resolveWithin, restorePath, restoreText, replaceProjectFilesInPlace, resolveWithin, restorePath, restoreText,
saveProjectWhitelist, saveRun, saveProjectWhitelist, saveRun,
} from "./velum-project"; } from "./velum-project";
@@ -128,6 +128,7 @@ export async function reviseProjectRun(args: {
Object.assign(textReplacements, plan.replacements); Object.assign(textReplacements, plan.replacements);
let replacementAttempted = false; let replacementAttempted = false;
let whitelistChanged = false; let whitelistChanged = false;
let publishedCopyPath: string | undefined;
try { try {
for (const file of previous.files) { for (const file of previous.files) {
const source = resolveWithin(join(previousDirectory, "files"), file.output); const source = resolveWithin(join(previousDirectory, "files"), file.output);
@@ -163,12 +164,18 @@ export async function reviseProjectRun(args: {
await saveRun(directory, run); await saveRun(directory, run);
replacementAttempted = true; replacementAttempted = true;
await replaceProjectFilesInPlace(previous.projectPath, directory, run.files); await replaceProjectFilesInPlace(previous.projectPath, directory, run.files);
} else await saveRun(directory, run); } else {
publishedCopyPath = await publishProjectCopies(run, directory);
run.copyPath = publishedCopyPath;
await saveRun(directory, run);
}
} catch (error) { } catch (error) {
// Keep the backup if replacing the project folder was attempted, even when // Keep the backup if replacing the project folder was attempted, even when
// its own rollback reports an error. // its own rollback reports an error.
if (!replacementAttempted) await fs.rm(directory, { recursive: true, force: true }); if (!replacementAttempted) {
else await fs.rm(join(directory, "run.json"), { force: true }); await fs.rm(directory, { recursive: true, force: true });
if (publishedCopyPath) await fs.rm(publishedCopyPath, { recursive: true, force: true });
} else await fs.rm(join(directory, "run.json"), { force: true });
if (whitelistChanged) { if (whitelistChanged) {
try { await saveProjectWhitelist(previous.projectPath, previousWhitelist, support); } try { await saveProjectWhitelist(previous.projectPath, previousWhitelist, support); }
catch (rollbackError) { catch (rollbackError) {

View File

@@ -32,6 +32,8 @@ export type ProjectRun = {
commentAuthorCoverage?: boolean; commentAuthorCoverage?: boolean;
/** Word tracked-change authors and deleted text were reviewed. */ /** Word tracked-change authors and deleted text were reviewed. */
reviewAuthorCoverage?: boolean; reviewAuthorCoverage?: boolean;
/** Visible copy next to the source project; immutable snapshots remain in Raycast support. */
copyPath?: string;
}; };
const DOCUMENTS = new Set([".docx", ".xlsx", ".pptx", ".pdf"]); const DOCUMENTS = new Set([".docx", ".xlsx", ".pptx", ".pdf"]);
@@ -299,6 +301,39 @@ export async function saveRun(directory: string, run: ProjectRun): Promise<void>
await fs.rename(tmp, dest); await fs.rename(tmp, dest);
} }
export function runCopiesPath(run: ProjectRun, directory: string): string {
return run.copyPath ?? join(directory, "files");
}
/** Publish only output files next to the project, without the private mapping. */
export async function publishProjectCopies(run: ProjectRun, directory: string): Promise<string> {
const projectPath = canonicalProjectPath(run.projectPath);
const parent = dirname(projectPath);
const target = join(parent, `${basename(projectPath)}-Velum-${run.id}`);
try {
await fs.lstat(target);
throw new Error(`Kopienordner bereits vorhanden: ${target}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const staging = await fs.mkdtemp(join(parent, `.${basename(projectPath)}-Velum-`));
try {
for (const file of run.files) {
const source = resolveWithin(join(directory, "files"), file.output);
if (!(await fs.lstat(source)).isFile()) throw new Error(`Keine reguläre Ausgabedatei: ${file.output}`);
const destination = resolveWithin(staging, file.output);
await fs.mkdir(dirname(destination), { recursive: true, mode: 0o700 });
await fs.copyFile(source, destination, fsConstants.COPYFILE_EXCL);
await fs.chmod(destination, 0o600);
}
await fs.rename(staging, target);
return target;
} catch (error) {
await fs.rm(staging, { recursive: true, force: true });
throw error;
}
}
export async function listRuns(projectPath: string, support = environment.supportPath): Promise<Array<{ export async function listRuns(projectPath: string, support = environment.supportPath): Promise<Array<{
run: ProjectRun; directory: string; run: ProjectRun; directory: string;
}>> { }>> {

View File

@@ -8,7 +8,7 @@ import type { EntityType, PlaceholderMapping } from "./types";
import { getEntityTypes, pseudonymize, pseudonymizeDocument, requireProjectCapabilities } from "./velum"; import { getEntityTypes, pseudonymize, pseudonymizeDocument, requireProjectCapabilities } from "./velum";
import type { DocumentHit } from "./velum"; import type { DocumentHit } from "./velum";
import { import {
createRun, digest, forceMasks, listRuns, loadProjectEntityTypes, loadProjectRules, parseProjectRules, replaceSpans, createRun, digest, forceMasks, listRuns, loadProjectEntityTypes, loadProjectRules, parseProjectRules, publishProjectCopies, replaceSpans,
loadProjectWhitelist, matchesPreviousOutput, replaceProjectFilesInPlace, resolveWithin, safeRelativePath, saveProjectRules, loadProjectWhitelist, matchesPreviousOutput, replaceProjectFilesInPlace, resolveWithin, safeRelativePath, saveProjectRules,
saveProjectEntityTypes, saveProjectWhitelist, saveRun, scanProjectFiles, toUtf16Offsets, whitelistKey, saveProjectEntityTypes, saveProjectWhitelist, saveRun, scanProjectFiles, toUtf16Offsets, whitelistKey,
} from "./lib/velum-project"; } from "./lib/velum-project";
@@ -251,7 +251,7 @@ function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: Proje
return next; return next;
}); });
} }
async function apply(inPlace = false) { async function apply(inPlace = true) {
if (!props.preview.drafts.length && !accepted.size) { if (!props.preview.drafts.length && !accepted.size) {
try { try {
const rejected = groups.map((group) => ({ type: group.type, original: group.original })); const rejected = groups.map((group) => ({ type: group.type, original: group.original }));
@@ -278,6 +278,7 @@ function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: Proje
const toast = await showToast({ style: Toast.Style.Animated, const toast = await showToast({ style: Toast.Style.Animated,
title: inPlace ? "Originaldateien werden ersetzt" : "Pseudonymisierte Kopien werden erstellt" }); title: inPlace ? "Originaldateien werden ersetzt" : "Pseudonymisierte Kopien werden erstellt" });
let directory: string | undefined; let directory: string | undefined;
let publishedCopyPath: string | undefined;
let persisted = false; let persisted = false;
try { try {
const created = await createRun(props.root); const created = await createRun(props.root);
@@ -336,6 +337,10 @@ function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: Proje
run.files.push(file); run.files.push(file);
replacements.push(file); replacements.push(file);
} }
if (!inPlace) {
publishedCopyPath = await publishProjectCopies(run, directory);
run.copyPath = publishedCopyPath;
}
await saveRun(directory, run); await saveRun(directory, run);
persisted = true; persisted = true;
if (inPlace) { if (inPlace) {
@@ -355,11 +360,12 @@ function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: Proje
toast.style = Toast.Style.Success; toast.style = Toast.Style.Success;
toast.title = inPlace ? `${replacements.length} Dateien ersetzt` : `${run.files.length} Kopien erstellt`; toast.title = inPlace ? `${replacements.length} Dateien ersetzt` : `${run.files.length} Kopien erstellt`;
toast.message = inPlace ? "Die Sicherung ist im Raycast-Datenordner gespeichert." toast.message = inPlace ? "Die Sicherung ist im Raycast-Datenordner gespeichert."
: "Die Kopien sind im Raycast-Datenordner gespeichert."; : "Die Kopien liegen neben dem Projektordner.";
setCompleted({ directory: join(directory, inPlace ? "backup" : "files"), inPlace, files: run.files.length }); setCompleted({ directory: inPlace ? join(directory, "backup") : publishedCopyPath!, inPlace, files: run.files.length });
await showInFinder(inPlace ? props.root : join(directory, "files")).catch(() => undefined); await showInFinder(inPlace ? props.root : publishedCopyPath!).catch(() => undefined);
} catch (error) { } catch (error) {
if (directory && !persisted) await fs.rm(directory, { recursive: true, force: true }); if (directory && !persisted) await fs.rm(directory, { recursive: true, force: true });
if (publishedCopyPath && !persisted) await fs.rm(publishedCopyPath, { recursive: true, force: true });
toast.style = Toast.Style.Failure; toast.style = Toast.Style.Failure;
toast.title = "Pseudonymisierung fehlgeschlagen"; toast.title = "Pseudonymisierung fehlgeschlagen";
toast.message = `${(error as Error).message}${persisted && directory ? ` Kopien/Sicherung: ${directory}` : ""}`; toast.message = `${(error as Error).message}${persisted && directory ? ` Kopien/Sicherung: ${directory}` : ""}`;
@@ -370,7 +376,7 @@ function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: Proje
<List.Item icon={Icon.CheckCircle} <List.Item icon={Icon.CheckCircle}
title={completed.inPlace ? "Projektdateien ersetzt" : `${completed.files} pseudonymisierte Kopien erstellt`} title={completed.inPlace ? "Projektdateien ersetzt" : `${completed.files} pseudonymisierte Kopien erstellt`}
subtitle={completed.inPlace ? "Die Originale wurden gesichert." subtitle={completed.inPlace ? "Die Originale wurden gesichert."
: "Die Originaldateien im Projektordner bleiben unverändert."} : completed.directory}
actions={<ActionPanel> actions={<ActionPanel>
<Action title={completed.inPlace ? "Sicherung im Finder anzeigen" : "Kopien im Finder anzeigen"} <Action title={completed.inPlace ? "Sicherung im Finder anzeigen" : "Kopien im Finder anzeigen"}
icon={Icon.Folder} onAction={() => showInFinder(completed.directory)} /> icon={Icon.Folder} onAction={() => showInFinder(completed.directory)} />
@@ -384,8 +390,8 @@ function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: Proje
<List.Item icon={Icon.Play} title="Pseudonymisierung starten" <List.Item icon={Icon.Play} title="Pseudonymisierung starten"
subtitle={`${selectedGroups.size} Zuordnungen · ${people.length} Personen · ${props.preview.retained.length} unveränderte Dateien übernommen`} subtitle={`${selectedGroups.size} Zuordnungen · ${people.length} Personen · ${props.preview.retained.length} unveränderte Dateien übernommen`}
actions={<ActionPanel> actions={<ActionPanel>
<Action title="Pseudonymisierte Kopien erstellen" icon={Icon.Document} onAction={() => apply()} /> <Action title="Originaldateien ersetzen" icon={Icon.Pencil} onAction={() => apply()} />
<Action title="Originaldateien ersetzen" icon={Icon.Pencil} onAction={() => apply(true)} /> <Action title="Pseudonymisierte Kopien erstellen" icon={Icon.Document} onAction={() => apply(false)} />
<Action title="Zur Trefferauswahl" icon={Icon.ArrowLeft} onAction={() => setStage("selection")} /> <Action title="Zur Trefferauswahl" icon={Icon.ArrowLeft} onAction={() => setStage("selection")} />
</ActionPanel>} /> </ActionPanel>} />
</List.Section> </List.Section>
@@ -409,8 +415,8 @@ function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: Proje
onAction={() => setPersonMerges((current) => { onAction={() => setPersonMerges((current) => {
const next = { ...current }; delete next[person.placeholder]; return next; const next = { ...current }; delete next[person.placeholder]; return next;
})} />} })} />}
<Action title="Pseudonymisierte Kopien erstellen" icon={Icon.Document} onAction={() => apply()} /> <Action title="Originaldateien ersetzen" icon={Icon.Pencil} onAction={() => apply()} />
<Action title="Originaldateien ersetzen" icon={Icon.Pencil} onAction={() => apply(true)} /> <Action title="Pseudonymisierte Kopien erstellen" icon={Icon.Document} onAction={() => apply(false)} />
</ActionPanel>} />; </ActionPanel>} />;
})} })}
</List.Section> </List.Section>
@@ -590,6 +596,6 @@ export default function Command() {
{availableTypes.map((type) => <Form.TagPicker.Item key={type} value={type} title={labelForType(type)} />)} {availableTypes.map((type) => <Form.TagPicker.Item key={type} value={type} title={labelForType(type)} />)}
</Form.TagPicker> </Form.TagPicker>
<Form.Description title="Projekt-Whitelist" text={`${whitelist.length} Begriffe werden bei diesem Ordner nicht mehr vorgeschlagen. Über die Aktionen kannst du sie wieder freigeben.`} /> <Form.Description title="Projekt-Whitelist" text={`${whitelist.length} Begriffe werden bei diesem Ordner nicht mehr vorgeschlagen. Über die Aktionen kannst du sie wieder freigeben.`} />
<Form.Description title="Ablauf" text="Kategorien wählen, Zuordnungen auswählen, Personen bei Bedarf zusammenfassen und dann Kopien erstellen oder Originale ersetzen." /> <Form.Description title="Ablauf" text="Kategorien wählen, Zuordnungen auswählen und Personen bei Bedarf zusammenfassen. Standardmäßig werden die Originaldateien nach Bestätigung ersetzt; Kopien sind als Alternative verfügbar." />
</Form>; </Form>;
} }

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 { canonicalProjectPath, isRestorableDocument, listAllRuns, resolveWithin, restorePath, restoreText } from "./lib/velum-project"; import { canonicalProjectPath, isRestorableDocument, listAllRuns, resolveWithin, restorePath, restoreText, runCopiesPath } 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";
@@ -69,7 +69,7 @@ function RevisionReview(props: { saved: SavedRun; onSaved: () => Promise<void> }
toast.title = "Projektstand aktualisiert"; toast.title = "Projektstand aktualisiert";
toast.message = `${plan.released.length} freigegeben · ${plan.merged.length} zusammengeführt`; toast.message = `${plan.released.length} freigegeben · ${plan.merged.length} zusammengeführt`;
pop(); pop();
await showInFinder(inPlace ? run.projectPath : join(result.directory, "files")).catch(() => undefined); await showInFinder(inPlace ? run.projectPath : runCopiesPath(result.run, result.directory)).catch(() => undefined);
} catch (error) { } catch (error) {
toast.style = Toast.Style.Failure; toast.style = Toast.Style.Failure;
toast.title = "Nachbearbeitung fehlgeschlagen"; toast.title = "Nachbearbeitung fehlgeschlagen";
@@ -199,8 +199,8 @@ function ProjectHistory(props: { projectName: string; entries: SavedRun[] }) {
subtitle={`${run.files.length} Dateien`} subtitle={`${run.files.length} Dateien`}
actions={<ActionPanel> actions={<ActionPanel>
<Action title="Pseudonymisierte Kopien im Finder anzeigen" icon={Icon.Folder} <Action title="Pseudonymisierte Kopien im Finder anzeigen" icon={Icon.Folder}
onAction={() => showInFinder(join(directory, "files"))} /> onAction={() => showInFinder(runCopiesPath(run, directory))} />
<Action.CopyToClipboard title="Speicherpfad kopieren" content={join(directory, "files")} /> <Action.CopyToClipboard title="Speicherpfad kopieren" content={runCopiesPath(run, directory)} />
<Action title="Dateien als Klartext exportieren" icon={Icon.Download} <Action title="Dateien als Klartext exportieren" icon={Icon.Download}
onAction={() => push(<FilePicker run={run} directory={directory} />)} /> onAction={() => push(<FilePicker run={run} directory={directory} />)} />
</ActionPanel>} />)} </ActionPanel>} />)}
@@ -233,8 +233,8 @@ export default function Command() {
...(entries.length > 1 ? [{ text: `${entries.length} Stände` }] : [])]} ...(entries.length > 1 ? [{ text: `${entries.length} Stände` }] : [])]}
actions={<ActionPanel> actions={<ActionPanel>
<Action title="Pseudonymisierte Kopien im Finder anzeigen" icon={Icon.Folder} <Action title="Pseudonymisierte Kopien im Finder anzeigen" icon={Icon.Folder}
onAction={() => showInFinder(join(directory, "files"))} /> onAction={() => showInFinder(runCopiesPath(run, directory))} />
<Action.CopyToClipboard title="Speicherpfad kopieren" content={join(directory, "files")} /> <Action.CopyToClipboard title="Speicherpfad kopieren" content={runCopiesPath(run, directory)} />
<Action title="Aktuellen Stand 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="Aktuelle Dateien als Klartext exportieren" icon={Icon.Download} <Action title="Aktuelle Dateien als Klartext exportieren" icon={Icon.Download}

View File

@@ -35,6 +35,9 @@ describe("Nachbearbeitung von Projektständen", () => {
expect(result.run.files[0].output).toBe("Falsch Positiv-PERSON_02.txt"); expect(result.run.files[0].output).toBe("Falsch Positiv-PERSON_02.txt");
expect(await fs.readFile(join(result.directory, "files", result.run.files[0].output), "utf8")) expect(await fs.readFile(join(result.directory, "files", result.run.files[0].output), "utf8"))
.toBe("Falsch Positiv traf PERSON_02 und PERSON_02. PERSON_02 blieb."); .toBe("Falsch Positiv traf PERSON_02 und PERSON_02. PERSON_02 blieb.");
expect(result.run.copyPath?.startsWith(join(root, "Quelle-Velum-"))).toBe(true);
expect(await fs.readFile(join(result.run.copyPath!, result.run.files[0].output), "utf8"))
.toBe("Falsch Positiv traf PERSON_02 und PERSON_02. PERSON_02 blieb.");
expect(result.run.mapping.PERSON_03.merged_into).toBe("PERSON_02"); expect(result.run.mapping.PERSON_03.merged_into).toBe("PERSON_02");
expect(result.run.mapping.PERSON_04.merged_into).toBe("PERSON_02"); expect(result.run.mapping.PERSON_04.merged_into).toBe("PERSON_02");
expect(result.run.mapping.PERSON_01).toBeUndefined(); expect(result.run.mapping.PERSON_01).toBeUndefined();

View File

@@ -53,6 +53,29 @@ describe("Velum-Projektstände", () => {
.toEqual([first.run.id, second.run.id].sort()); .toEqual([first.run.id, second.run.id].sort());
}); });
it("legt pseudonymisierte Kopien neben dem Projekt ab und behält die Tabelle privat", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-sibling-copy-test-"));
roots.push(root);
const source = join(root, "Quelle");
const support = join(root, "Raycast");
await fs.mkdir(source);
const created = await project.createRun(source, support);
created.run.mapping = { PERSON_01: { original: "Max Mustermann", type: "PERSON" } };
created.run.files = [{ source: "Unterordner/Name.txt", output: "Unterordner/PERSON_01.txt", kind: "text" }];
await fs.mkdir(join(created.directory, "files", "Unterordner"));
await fs.writeFile(join(created.directory, "files", "Unterordner", "PERSON_01.txt"), "PERSON_01");
const copyPath = await project.publishProjectCopies(created.run, created.directory);
created.run.copyPath = copyPath;
await project.saveRun(created.directory, created.run);
expect(copyPath.startsWith(join(root, "Quelle-Velum-"))).toBe(true);
expect(project.runCopiesPath(created.run, created.directory)).toBe(copyPath);
expect(await fs.readFile(join(copyPath, "Unterordner", "PERSON_01.txt"), "utf8")).toBe("PERSON_01");
expect(await fs.readdir(copyPath)).toEqual(["Unterordner"]);
expect(await fs.readFile(join(created.directory, "run.json"), "utf8")).toContain("Max Mustermann");
await expect(project.publishProjectCopies(created.run, created.directory)).rejects.toThrow("bereits vorhanden");
expect((await project.listRuns(source, support))[0].run.copyPath).toBe(copyPath);
});
it("behält die Kategorieauswahl pro Projekt für den nächsten Lauf", async () => { it("behält die Kategorieauswahl pro Projekt für den nächsten Lauf", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "velum-entity-types-test-")); const root = await fs.mkdtemp(join(tmpdir(), "velum-entity-types-test-"));
roots.push(root); roots.push(root);