fix: restore project placeholders regardless of case
This commit is contained in:
@@ -244,24 +244,41 @@ export function replaceSpans(
|
|||||||
return output;
|
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);
|
const keys = Object.keys(mapping).sort((a, b) => b.length - a.length);
|
||||||
if (!keys.length) return undefined;
|
if (!keys.length) return undefined;
|
||||||
|
const foldedKeys = new Map<string, string | null>();
|
||||||
|
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 parts = keys.map((key) => {
|
||||||
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
return pathSegment || matchModes?.[key] === "anywhere"
|
return pathSegment || matchModes?.[key] === "anywhere"
|
||||||
? escaped : `(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`;
|
? escaped : `(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`;
|
||||||
});
|
});
|
||||||
return new RegExp(parts.join("|"), "gu");
|
return {
|
||||||
|
pattern: new RegExp(parts.join("|"), "giu"),
|
||||||
|
originalFor: (match) => {
|
||||||
|
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,
|
export function restoreTextWithCount(source: string, mapping: PlaceholderMapping,
|
||||||
matchModes?: ProjectRun["matchModes"]): { text: string; replacements: number } {
|
matchModes?: ProjectRun["matchModes"]): { text: string; replacements: number } {
|
||||||
const pattern = restorePattern(mapping, matchModes);
|
const pattern = restorePattern(mapping, matchModes);
|
||||||
let replacements = 0;
|
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++;
|
replacements++;
|
||||||
return mapping[key].original;
|
return original;
|
||||||
}) : source;
|
}) : source;
|
||||||
return { text, replacements };
|
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.
|
// Restricting replacements to word boundaries would leave those names masked.
|
||||||
const pattern = restorePattern(mapping, matchModes, true);
|
const pattern = restorePattern(mapping, matchModes, true);
|
||||||
const segments = safeRelativePath(relPath).split("/").map((segment) => {
|
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 === "..") {
|
if (restored.includes("/") || restored.includes("\\") || restored === "." || restored === "..") {
|
||||||
throw new Error("Rückübersetzter Dateiname enthält einen ungültigen Pfadteil.");
|
throw new Error("Rückübersetzter Dateiname enthält einen ungültigen Pfadteil.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, 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 type { ProjectFile, 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";
|
||||||
@@ -173,10 +173,13 @@ function FilePicker(props: { run: ProjectRun; directory: string; historical?: bo
|
|||||||
const parent = join(homedir(), "Downloads", "Velum");
|
const parent = join(homedir(), "Downloads", "Velum");
|
||||||
let targetRoot: string | undefined;
|
let targetRoot: string | undefined;
|
||||||
try {
|
try {
|
||||||
|
if (!Object.keys(props.run.mapping).length) throw new Error("Dieser Projektstand hat keine Übersetzungstabelle.");
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
const planned = files.filter((item) => selected.has(item.relPath)).map((file) => ({
|
const planned = files.filter((item) => selected.has(item.relPath)).map((file) => ({
|
||||||
file, restoredName: restorePath(file.relPath, props.run.mapping, props.run.matchModes),
|
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) {
|
for (const { restoredName } of planned) {
|
||||||
if (names.has(restoredName)) throw new Error(`Mehrdeutiger Ausgabename: ${restoredName}`);
|
if (names.has(restoredName)) throw new Error(`Mehrdeutiger Ausgabename: ${restoredName}`);
|
||||||
names.add(restoredName);
|
names.add(restoredName);
|
||||||
@@ -188,14 +191,24 @@ function FilePicker(props: { run: ProjectRun; directory: string; historical?: bo
|
|||||||
const target = resolveWithin(targetRoot, restoredName);
|
const target = resolveWithin(targetRoot, restoredName);
|
||||||
if (!(await fs.lstat(source)).isFile()) throw new Error(`Keine reguläre Datei: ${file.relPath}`);
|
if (!(await fs.lstat(source)).isFile()) throw new Error(`Keine reguläre Datei: ${file.relPath}`);
|
||||||
const data = await fs.readFile(source);
|
const data = await fs.readFile(source);
|
||||||
const result = file.kind === "document"
|
let result: Buffer;
|
||||||
? await depseudonymizeDocument(basename(file.relPath), data, props.run.mapping)
|
if (file.kind === "document") {
|
||||||
: Buffer.from(restoreText(data.toString("utf8"), props.run.mapping, props.run.matchModes), "utf8");
|
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.mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
||||||
await fs.writeFile(target, result, { flag: "wx", mode: 0o600 });
|
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.style = Toast.Style.Success;
|
||||||
toast.title = `${selected.size} Klartext-Dateien in Downloads`;
|
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);
|
await showInFinder(targetRoot).catch(() => undefined);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (targetRoot) await fs.rm(targetRoot, { recursive: true, force: true });
|
if (targetRoot) await fs.rm(targetRoot, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -132,6 +132,12 @@ describe("Velum-Projektstände", () => {
|
|||||||
expect(project.restorePath("Archiv/BerichtPERSON_01.docx", {
|
expect(project.restorePath("Archiv/BerichtPERSON_01.docx", {
|
||||||
PERSON_01: { original: "Max Mustermann", type: "PERSON" },
|
PERSON_01: { original: "Max Mustermann", type: "PERSON" },
|
||||||
})).toBe("Archiv/BerichtMax Mustermann.docx");
|
})).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", {
|
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");
|
||||||
@@ -139,6 +145,10 @@ describe("Velum-Projektstände", () => {
|
|||||||
PERSON_01: { original: "Falsch", type: "PERSON" },
|
PERSON_01: { original: "Falsch", type: "PERSON" },
|
||||||
PERSON_0101: { original: "Richtig", type: "PERSON" },
|
PERSON_0101: { original: "Richtig", type: "PERSON" },
|
||||||
})).toBe("Richtig.txt");
|
})).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();
|
expect(() => project.safeRelativePath("../geheim.txt")).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user