From 7db3949d6659454afc64829895f1f7bcd9ec30b6 Mon Sep 17 00:00:00 2001 From: muena Date: Tue, 6 Oct 2026 13:38:21 +0200 Subject: [PATCH] fix: restore project placeholders regardless of case --- src/lib/velum-project.ts | 27 ++++++++++++++++++++++----- src/restore-project-files.tsx | 21 +++++++++++++++++---- tests/velum-project.test.ts | 10 ++++++++++ 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/lib/velum-project.ts b/src/lib/velum-project.ts index 28c1b12..3944d99 100644 --- a/src/lib/velum-project.ts +++ b/src/lib/velum-project.ts @@ -244,24 +244,41 @@ export function replaceSpans( return output; } -function restorePattern(mapping: PlaceholderMapping, matchModes: ProjectRun["matchModes"], pathSegment = false): RegExp | undefined { +function restorePattern(mapping: PlaceholderMapping, matchModes: ProjectRun["matchModes"], pathSegment = false): { + pattern: RegExp; originalFor: (match: string) => string | undefined; +} | undefined { const keys = Object.keys(mapping).sort((a, b) => b.length - a.length); if (!keys.length) return undefined; + const foldedKeys = new Map(); + for (const key of keys) { + const folded = key.toUpperCase(); + if (foldedKeys.has(folded)) foldedKeys.set(folded, null); + else foldedKeys.set(folded, key); + } const parts = keys.map((key) => { const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return pathSegment || matchModes?.[key] === "anywhere" ? escaped : `(? { + const key = Object.prototype.hasOwnProperty.call(mapping, match) + ? match : foldedKeys.get(match.toUpperCase()); + return key ? mapping[key].original : undefined; + }, + }; } export function restoreTextWithCount(source: string, mapping: PlaceholderMapping, matchModes?: ProjectRun["matchModes"]): { text: string; replacements: number } { const pattern = restorePattern(mapping, matchModes); let replacements = 0; - const text = pattern ? source.replace(pattern, (key) => { + const text = pattern ? source.replace(pattern.pattern, (match) => { + const original = pattern.originalFor(match); + if (original === undefined) return match; replacements++; - return mapping[key].original; + return original; }) : source; return { text, replacements }; } @@ -275,7 +292,7 @@ export function restorePath(relPath: string, mapping: PlaceholderMapping, matchM // Restricting replacements to word boundaries would leave those names masked. const pattern = restorePattern(mapping, matchModes, true); const segments = safeRelativePath(relPath).split("/").map((segment) => { - const restored = pattern ? segment.replace(pattern, (key) => mapping[key].original) : segment; + const restored = pattern ? segment.replace(pattern.pattern, (match) => pattern.originalFor(match) ?? match) : segment; if (restored.includes("/") || restored.includes("\\") || restored === "." || restored === "..") { throw new Error("Rückübersetzter Dateiname enthält einen ungültigen Pfadteil."); } diff --git a/src/restore-project-files.tsx b/src/restore-project-files.tsx index c843d7a..3bf88a4 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, listAllRuns, resolveWithin, restorePath, restoreText, runCopiesPath, scanRestorableCopyFiles } from "./lib/velum-project"; +import { canonicalProjectPath, listAllRuns, resolveWithin, restorePath, restoreTextWithCount, runCopiesPath, scanRestorableCopyFiles } from "./lib/velum-project"; import type { ProjectFile, ProjectRun } from "./lib/velum-project"; import { canonicalPerson, mergePeople } from "./lib/project-review"; import { planProjectRevision, reviseProjectRun } from "./lib/project-revision"; @@ -173,10 +173,13 @@ function FilePicker(props: { run: ProjectRun; directory: string; historical?: bo const parent = join(homedir(), "Downloads", "Velum"); let targetRoot: string | undefined; try { + if (!Object.keys(props.run.mapping).length) throw new Error("Dieser Projektstand hat keine Übersetzungstabelle."); const names = new Set(); const planned = files.filter((item) => selected.has(item.relPath)).map((file) => ({ file, restoredName: restorePath(file.relPath, props.run.mapping, props.run.matchModes), })); + const renamedFiles = planned.filter(({ file, restoredName }) => restoredName !== file.relPath).length; + let contentReplacements = 0; for (const { restoredName } of planned) { if (names.has(restoredName)) throw new Error(`Mehrdeutiger Ausgabename: ${restoredName}`); names.add(restoredName); @@ -188,14 +191,24 @@ function FilePicker(props: { run: ProjectRun; directory: string; historical?: bo const target = resolveWithin(targetRoot, restoredName); if (!(await fs.lstat(source)).isFile()) throw new Error(`Keine reguläre Datei: ${file.relPath}`); const data = await fs.readFile(source); - const result = file.kind === "document" - ? await depseudonymizeDocument(basename(file.relPath), data, props.run.mapping) - : Buffer.from(restoreText(data.toString("utf8"), props.run.mapping, props.run.matchModes), "utf8"); + let result: Buffer; + if (file.kind === "document") { + result = await depseudonymizeDocument(basename(file.relPath), data, props.run.mapping); + } else { + const restored = restoreTextWithCount(data.toString("utf8"), props.run.mapping, props.run.matchModes); + contentReplacements += restored.replacements; + result = Buffer.from(restored.text, "utf8"); + } await fs.mkdir(dirname(target), { recursive: true, mode: 0o700 }); await fs.writeFile(target, result, { flag: "wx", mode: 0o600 }); } + if (!renamedFiles && !contentReplacements && planned.every(({ file }) => file.kind === "text")) { + throw new Error("Keine passenden Velum-Platzhalter in den ausgewählten Textdateien oder Dateinamen gefunden. Bitte Quelle und Projektstand prüfen."); + } toast.style = Toast.Style.Success; toast.title = `${selected.size} Klartext-Dateien in Downloads`; + toast.message = `${contentReplacements} Textersetzungen · ${renamedFiles} Dateinamen geändert` + + (planned.some(({ file }) => file.kind === "document") ? " · Office-Inhalte nicht gezählt" : ""); await showInFinder(targetRoot).catch(() => undefined); } catch (error) { if (targetRoot) await fs.rm(targetRoot, { recursive: true, force: true }); diff --git a/tests/velum-project.test.ts b/tests/velum-project.test.ts index 59a533b..cfa3586 100644 --- a/tests/velum-project.test.ts +++ b/tests/velum-project.test.ts @@ -132,6 +132,12 @@ describe("Velum-Projektstände", () => { expect(project.restorePath("Archiv/BerichtPERSON_01.docx", { PERSON_01: { original: "Max Mustermann", type: "PERSON" }, })).toBe("Archiv/BerichtMax Mustermann.docx"); + expect(project.restorePath("archiv/bericht_person_01.docx", { + PERSON_01: { original: "Max Mustermann", type: "PERSON" }, + })).toBe("archiv/bericht_Max Mustermann.docx"); + expect(project.restoreTextWithCount("person_01 und Person_01", { + PERSON_01: { original: "Max Mustermann", type: "PERSON" }, + })).toEqual({ text: "Max Mustermann und Max Mustermann", replacements: 2 }); expect(project.restoreText("BerichtPERSON_01", { PERSON_01: { original: "Max Mustermann", type: "PERSON" }, })).toBe("BerichtPERSON_01"); @@ -139,6 +145,10 @@ describe("Velum-Projektstände", () => { PERSON_01: { original: "Falsch", type: "PERSON" }, PERSON_0101: { original: "Richtig", type: "PERSON" }, })).toBe("Richtig.txt"); + expect(project.restoreText("Person_01 / PERSON_01", { + PERSON_01: { original: "Eins", type: "PERSON" }, + person_01: { original: "Zwei", type: "PERSON" }, + })).toBe("Person_01 / Eins"); expect(() => project.safeRelativePath("../geheim.txt")).toThrow(); });