fix: preserve placeholder case when restoring project text

This commit is contained in:
muena
2026-10-06 13:43:33 +02:00
parent 7db3949d66
commit 818f9ed814
5 changed files with 54 additions and 5 deletions

17
src/lib/restore-case.ts Normal file
View File

@@ -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;
}

View File

@@ -1,5 +1,6 @@
import type { PlaceholderMapping } from "../types"; import type { PlaceholderMapping } from "../types";
import type { ProjectRun } from "./velum-project"; import type { ProjectRun } from "./velum-project";
import { restoreOriginalCase } from "./restore-case";
function escapeRegExp(value: string): string { function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -98,6 +99,13 @@ function replaceNode(
session: boolean, session: boolean,
): { value: string; count: number } { ): { value: string; count: number } {
const candidates: Match[] = []; const candidates: Match[] = [];
const foldedCounts = new Map<string, number>();
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)) { for (const [key, entry] of Object.entries(mapping)) {
const stripped = key.replace(/[<>]/g, ""); const stripped = key.replace(/[<>]/g, "");
const bare = session ? stripped : key; const bare = session ? stripped : key;
@@ -105,7 +113,7 @@ function replaceNode(
matchModes?.[key] === "anywhere" matchModes?.[key] === "anywhere"
? escapeRegExp(bare) ? escapeRegExp(bare)
: `(?<![\\p{L}\\p{N}])${escapeRegExp(bare)}(?![\\p{L}\\p{N}])`; : `(?<![\\p{L}\\p{N}])${escapeRegExp(bare)}(?![\\p{L}\\p{N}])`;
const patterns = [new RegExp(boundary, "gu")]; const patterns = [new RegExp(boundary, session ? "gu" : "giu")];
if (session) { if (session) {
patterns.unshift( patterns.unshift(
new RegExp( new RegExp(
@@ -116,10 +124,12 @@ function replaceNode(
} }
for (const pattern of patterns) { for (const pattern of patterns) {
for (const match of value.matchAll(pattern)) { for (const match of value.matchAll(pattern)) {
if (!session && match[0] !== key && (Object.prototype.hasOwnProperty.call(mapping, match[0])
|| foldedCounts.get(key.toUpperCase()) !== 1)) continue;
candidates.push({ candidates.push({
start: match.index, start: match.index,
end: match.index + match[0].length, end: match.index + match[0].length,
replacement: escapeHtml(entry.original), replacement: escapeHtml(session ? entry.original : restoreOriginalCase(match[0], key, entry.original)),
}); });
} }
} }

View File

@@ -5,6 +5,7 @@ import { environment } from "@raycast/api";
import type { EntityType, PlaceholderMapping } from "../types"; import type { EntityType, PlaceholderMapping } from "../types";
import { whitelistKey } from "./project-whitelist"; import { whitelistKey } from "./project-whitelist";
import type { ProjectWhitelistEntry } from "./project-whitelist"; import type { ProjectWhitelistEntry } from "./project-whitelist";
import { restoreOriginalCase } from "./restore-case";
export { whitelistKey } from "./project-whitelist"; export { whitelistKey } from "./project-whitelist";
export type { ProjectWhitelistEntry } from "./project-whitelist"; export type { ProjectWhitelistEntry } from "./project-whitelist";
@@ -265,7 +266,7 @@ function restorePattern(mapping: PlaceholderMapping, matchModes: ProjectRun["mat
originalFor: (match) => { originalFor: (match) => {
const key = Object.prototype.hasOwnProperty.call(mapping, match) const key = Object.prototype.hasOwnProperty.call(mapping, match)
? match : foldedKeys.get(match.toUpperCase()); ? match : foldedKeys.get(match.toUpperCase());
return key ? mapping[key].original : undefined; return key ? restoreOriginalCase(match, key, mapping[key].original) : undefined;
}, },
}; };
} }

View File

@@ -74,4 +74,16 @@ describe("formatierte Textwiederherstellung", () => {
), ),
).toBe("<b>abcMax Mustermannxyz</b>"); ).toBe("<b>abcMax Mustermannxyz</b>");
}); });
it("erhält HTML-Formatierung auch bei anders geschriebenen Platzhaltern", () => {
const html = "<b>person_01</b> und <i>Person_01</i>";
expect(restoreRichHtml(html, "person_01 und Person_01",
"max mustermann und Max Mustermann", mapping))
.toBe("<b>max mustermann</b> und <i>Max Mustermann</i>");
expect(restoreRichHtml("<b>Person_01</b> / <i>PERSON_01</i>",
"Person_01 / PERSON_01", "Person_01 / Eins", {
PERSON_01: { original: "Eins", type: "PERSON" },
person_01: { original: "Zwei", type: "PERSON" },
})).toBe("<b>Person_01</b> / <i>Eins</i>");
});
}); });

View File

@@ -134,10 +134,19 @@ describe("Velum-Projektstände", () => {
})).toBe("Archiv/BerichtMax Mustermann.docx"); })).toBe("Archiv/BerichtMax Mustermann.docx");
expect(project.restorePath("archiv/bericht_person_01.docx", { expect(project.restorePath("archiv/bericht_person_01.docx", {
PERSON_01: { original: "Max Mustermann", type: "PERSON" }, 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", { expect(project.restoreTextWithCount("person_01 und Person_01", {
PERSON_01: { original: "Max Mustermann", type: "PERSON" }, 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", { expect(project.restoreText("BerichtPERSON_01", {
PERSON_01: { original: "Max Mustermann", type: "PERSON" }, PERSON_01: { original: "Max Mustermann", type: "PERSON" },
})).toBe("BerichtPERSON_01"); })).toBe("BerichtPERSON_01");