From 818f9ed8140b7e6864cb7df07720e191ab8094c2 Mon Sep 17 00:00:00 2001 From: muena Date: Tue, 6 Oct 2026 13:43:33 +0200 Subject: [PATCH] fix: preserve placeholder case when restoring project text --- src/lib/restore-case.ts | 17 +++++++++++++++++ src/lib/rich-text.ts | 14 ++++++++++++-- src/lib/velum-project.ts | 3 ++- tests/rich-text.test.ts | 12 ++++++++++++ tests/velum-project.test.ts | 13 +++++++++++-- 5 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 src/lib/restore-case.ts diff --git a/src/lib/restore-case.ts b/src/lib/restore-case.ts new file mode 100644 index 0000000..dd2e0e8 --- /dev/null +++ b/src/lib/restore-case.ts @@ -0,0 +1,17 @@ +/** Keep the saved spelling for the canonical placeholder and adapt edited case variants. */ +export function restoreOriginalCase(match: string, key: string, original: string): string { + if (match === key) return original; + const letters = match.replace(/[^\p{L}]/gu, ""); + if (!letters) return original; + if (letters === letters.toLocaleLowerCase("de")) return original.toLocaleLowerCase("de"); + if (letters === letters.toLocaleUpperCase("de")) return original.toLocaleUpperCase("de"); + if (/^\p{Lu}\p{Ll}+$/u.test(letters)) { + const originalLetters = original.replace(/[^\p{L}]/gu, ""); + if (originalLetters && (originalLetters === originalLetters.toLocaleLowerCase("de") + || originalLetters === originalLetters.toLocaleUpperCase("de"))) { + return original.toLocaleLowerCase("de").replace(/(^|[^\p{L}])(\p{L})/gu, + (_, prefix: string, letter: string) => prefix + letter.toLocaleUpperCase("de")); + } + } + return original; +} diff --git a/src/lib/rich-text.ts b/src/lib/rich-text.ts index f9ba0b2..c9bfe46 100644 --- a/src/lib/rich-text.ts +++ b/src/lib/rich-text.ts @@ -1,5 +1,6 @@ import type { PlaceholderMapping } from "../types"; import type { ProjectRun } from "./velum-project"; +import { restoreOriginalCase } from "./restore-case"; function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -98,6 +99,13 @@ function replaceNode( session: boolean, ): { value: string; count: number } { const candidates: Match[] = []; + const foldedCounts = new Map(); + if (!session) { + for (const key of Object.keys(mapping)) { + const folded = key.toUpperCase(); + foldedCounts.set(folded, (foldedCounts.get(folded) ?? 0) + 1); + } + } for (const [key, entry] of Object.entries(mapping)) { const stripped = key.replace(/[<>]/g, ""); const bare = session ? stripped : key; @@ -105,7 +113,7 @@ function replaceNode( matchModes?.[key] === "anywhere" ? escapeRegExp(bare) : `(? { const key = Object.prototype.hasOwnProperty.call(mapping, match) ? match : foldedKeys.get(match.toUpperCase()); - return key ? mapping[key].original : undefined; + return key ? restoreOriginalCase(match, key, mapping[key].original) : undefined; }, }; } diff --git a/tests/rich-text.test.ts b/tests/rich-text.test.ts index 7ad5dbf..4c2550c 100644 --- a/tests/rich-text.test.ts +++ b/tests/rich-text.test.ts @@ -74,4 +74,16 @@ describe("formatierte Textwiederherstellung", () => { ), ).toBe("abcMax Mustermannxyz"); }); + + it("erhält HTML-Formatierung auch bei anders geschriebenen Platzhaltern", () => { + const html = "person_01 und Person_01"; + expect(restoreRichHtml(html, "person_01 und Person_01", + "max mustermann und Max Mustermann", mapping)) + .toBe("max mustermann und Max Mustermann"); + expect(restoreRichHtml("Person_01 / PERSON_01", + "Person_01 / PERSON_01", "Person_01 / Eins", { + PERSON_01: { original: "Eins", type: "PERSON" }, + person_01: { original: "Zwei", type: "PERSON" }, + })).toBe("Person_01 / Eins"); + }); }); diff --git a/tests/velum-project.test.ts b/tests/velum-project.test.ts index cfa3586..6f48615 100644 --- a/tests/velum-project.test.ts +++ b/tests/velum-project.test.ts @@ -134,10 +134,19 @@ describe("Velum-Projektstände", () => { })).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"); + })).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 }); + })).toEqual({ text: "max mustermann und Max Mustermann", replacements: 2 }); + expect(project.restoreText("PERSON_01 / person_01 / Person_01", { + PERSON_01: { original: "Max Mustermann", type: "PERSON" }, + })).toBe("Max Mustermann / max mustermann / Max Mustermann"); + expect(project.restoreText("KUNDE_01 / kunde_01 / Kunde_01", { + KUNDE_01: { original: "ACME GMBH", type: "KUNDE" }, + })).toBe("ACME GMBH / acme gmbh / Acme Gmbh"); + expect(project.restoreText("person_01 / PERSON_01", { + person_01: { original: "max mustermann", type: "PERSON" }, + })).toBe("max mustermann / MAX MUSTERMANN"); expect(project.restoreText("BerichtPERSON_01", { PERSON_01: { original: "Max Mustermann", type: "PERSON" }, })).toBe("BerichtPERSON_01");