diff --git a/src/lib/project-revision.ts b/src/lib/project-revision.ts index 04bb822..87f9989 100644 --- a/src/lib/project-revision.ts +++ b/src/lib/project-revision.ts @@ -3,7 +3,7 @@ import { basename, dirname, join } from "node:path"; import type { PlaceholderMapping } from "../types"; import { canonicalPerson } from "./project-review"; import { - createRun, digest, isRestorableDocument, listRuns, loadProjectWhitelist, + createRun, digest, isRestorableDocument, listRuns, loadProjectWhitelist, publishProjectCopies, replaceProjectFilesInPlace, resolveWithin, restorePath, restoreText, saveProjectWhitelist, saveRun, } from "./velum-project"; @@ -128,6 +128,7 @@ export async function reviseProjectRun(args: { Object.assign(textReplacements, plan.replacements); let replacementAttempted = false; let whitelistChanged = false; + let publishedCopyPath: string | undefined; try { for (const file of previous.files) { const source = resolveWithin(join(previousDirectory, "files"), file.output); @@ -163,12 +164,18 @@ export async function reviseProjectRun(args: { await saveRun(directory, run); replacementAttempted = true; 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) { // Keep the backup if replacing the project folder was attempted, even when // its own rollback reports an error. - if (!replacementAttempted) await fs.rm(directory, { recursive: true, force: true }); - else await fs.rm(join(directory, "run.json"), { force: true }); + if (!replacementAttempted) { + 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) { try { await saveProjectWhitelist(previous.projectPath, previousWhitelist, support); } catch (rollbackError) { diff --git a/src/lib/velum-project.ts b/src/lib/velum-project.ts index 492c969..bc9e472 100644 --- a/src/lib/velum-project.ts +++ b/src/lib/velum-project.ts @@ -32,6 +32,8 @@ export type ProjectRun = { commentAuthorCoverage?: boolean; /** Word tracked-change authors and deleted text were reviewed. */ 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"]); @@ -299,6 +301,39 @@ export async function saveRun(directory: string, run: ProjectRun): Promise 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 { + 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> { diff --git a/src/pseudonymize-project-files.tsx b/src/pseudonymize-project-files.tsx index 4c19eeb..8648d03 100644 --- a/src/pseudonymize-project-files.tsx +++ b/src/pseudonymize-project-files.tsx @@ -8,7 +8,7 @@ import type { EntityType, PlaceholderMapping } from "./types"; import { getEntityTypes, pseudonymize, pseudonymizeDocument, requireProjectCapabilities } from "./velum"; import type { DocumentHit } from "./velum"; import { - createRun, digest, forceMasks, listRuns, loadProjectEntityTypes, loadProjectRules, parseProjectRules, replaceSpans, + createRun, digest, forceMasks, listRuns, loadProjectEntityTypes, loadProjectRules, parseProjectRules, publishProjectCopies, replaceSpans, loadProjectWhitelist, matchesPreviousOutput, replaceProjectFilesInPlace, resolveWithin, safeRelativePath, saveProjectRules, saveProjectEntityTypes, saveProjectWhitelist, saveRun, scanProjectFiles, toUtf16Offsets, whitelistKey, } from "./lib/velum-project"; @@ -251,7 +251,7 @@ function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: Proje return next; }); } - async function apply(inPlace = false) { + async function apply(inPlace = true) { if (!props.preview.drafts.length && !accepted.size) { try { 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, title: inPlace ? "Originaldateien werden ersetzt" : "Pseudonymisierte Kopien werden erstellt" }); let directory: string | undefined; + let publishedCopyPath: string | undefined; let persisted = false; try { const created = await createRun(props.root); @@ -336,6 +337,10 @@ function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: Proje run.files.push(file); replacements.push(file); } + if (!inPlace) { + publishedCopyPath = await publishProjectCopies(run, directory); + run.copyPath = publishedCopyPath; + } await saveRun(directory, run); persisted = true; if (inPlace) { @@ -355,11 +360,12 @@ function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: Proje toast.style = Toast.Style.Success; toast.title = inPlace ? `${replacements.length} Dateien ersetzt` : `${run.files.length} Kopien erstellt`; toast.message = inPlace ? "Die Sicherung ist im Raycast-Datenordner gespeichert." - : "Die Kopien sind im Raycast-Datenordner gespeichert."; - setCompleted({ directory: join(directory, inPlace ? "backup" : "files"), inPlace, files: run.files.length }); - await showInFinder(inPlace ? props.root : join(directory, "files")).catch(() => undefined); + : "Die Kopien liegen neben dem Projektordner."; + setCompleted({ directory: inPlace ? join(directory, "backup") : publishedCopyPath!, inPlace, files: run.files.length }); + await showInFinder(inPlace ? props.root : publishedCopyPath!).catch(() => undefined); } catch (error) { 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.title = "Pseudonymisierung fehlgeschlagen"; toast.message = `${(error as Error).message}${persisted && directory ? ` Kopien/Sicherung: ${directory}` : ""}`; @@ -370,7 +376,7 @@ function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: Proje showInFinder(completed.directory)} /> @@ -384,8 +390,8 @@ function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: Proje - apply()} /> - apply(true)} /> + apply()} /> + apply(false)} /> setStage("selection")} /> } /> @@ -409,8 +415,8 @@ function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: Proje onAction={() => setPersonMerges((current) => { const next = { ...current }; delete next[person.placeholder]; return next; })} />} - apply()} /> - apply(true)} /> + apply()} /> + apply(false)} /> } />; })} @@ -590,6 +596,6 @@ export default function Command() { {availableTypes.map((type) => )} - + ; } diff --git a/src/restore-project-files.tsx b/src/restore-project-files.tsx index 49d25a2..9512d00 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 { 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 { canonicalPerson, mergePeople } from "./lib/project-review"; import { planProjectRevision, reviseProjectRun } from "./lib/project-revision"; @@ -69,7 +69,7 @@ function RevisionReview(props: { saved: SavedRun; onSaved: () => Promise } toast.title = "Projektstand aktualisiert"; toast.message = `${plan.released.length} freigegeben · ${plan.merged.length} zusammengeführt`; 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) { toast.style = Toast.Style.Failure; toast.title = "Nachbearbeitung fehlgeschlagen"; @@ -199,8 +199,8 @@ function ProjectHistory(props: { projectName: string; entries: SavedRun[] }) { subtitle={`${run.files.length} Dateien`} actions={ showInFinder(join(directory, "files"))} /> - + onAction={() => showInFinder(runCopiesPath(run, directory))} /> + push()} /> } />)} @@ -233,8 +233,8 @@ export default function Command() { ...(entries.length > 1 ? [{ text: `${entries.length} Stände` }] : [])]} actions={ showInFinder(join(directory, "files"))} /> - + onAction={() => showInFinder(runCopiesPath(run, directory))} /> + push()} /> { 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")) .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_04.merged_into).toBe("PERSON_02"); expect(result.run.mapping.PERSON_01).toBeUndefined(); diff --git a/tests/velum-project.test.ts b/tests/velum-project.test.ts index aa17b0d..8dc40e1 100644 --- a/tests/velum-project.test.ts +++ b/tests/velum-project.test.ts @@ -53,6 +53,29 @@ describe("Velum-Projektstände", () => { .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 () => { const root = await fs.mkdtemp(join(tmpdir(), "velum-entity-types-test-")); roots.push(root);