Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/lib/import-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,9 @@ function categoryToNoteType(category: string): string {
return map[lower] ?? category.toUpperCase();
}

/** Extracts text from segment source/target, handling inline elements per XLIFF 2.0. */
/** Extracts text from segment source/target, handling inline elements per XLIFF 2.0.
* Returns the already-decoded in-memory string; JSON encoding is left to `toJSON()`.
*/
function extractSegmentText(segment: unknown): string {
if (segment == null) return "";
const seg = segment as Record<string, unknown>;
Expand Down
28 changes: 14 additions & 14 deletions src/lib/pgs-mf2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,18 +278,6 @@ export interface PgsSegmentImport {
body: string;
}

function parsePatternFromSegmentBody(body: string): unknown[] {
const trimmed = body.trim();
if (!trimmed) return [];
try {
const m = parseMessage(trimmed);
if ((m as { type?: string }).type !== "message") return [];
return (m as { pattern: unknown[] }).pattern;
} catch {
return [];
}
}

function ensureFallbackVariant(
keysCount: number,
variants: SelectMessage["variants"]
Expand All @@ -313,6 +301,9 @@ function ensureFallbackVariant(

/**
* Builds MF2 source string from PGS `pgs:switch` and segment bodies.
*
* Segment bodies are spliced into quoted patterns as-is so XLIFF `\` sequences
* are not dropped (`\t`/`\n` are invalid MF2 escapes) or double-escaped.
*/
export function pgsImportToSelectMessage(
switchAttr: string,
Expand Down Expand Up @@ -374,7 +365,9 @@ export function pgsImportToSelectMessage(
);
variants.push({
keys,
value: parsePatternFromSegmentBody(seg.body),
// Empty pattern: stringify emits `{{}}`, then we splice the raw XLIFF
// body so `\t` / `\n` / `\{` are not dropped or double-escaped.
value: [],
});
}

Expand All @@ -397,9 +390,16 @@ export function pgsImportToSelectMessage(
return null;
}

return stringifyMessage(
const bodies = segments.map((seg) => seg.body);
while (bodies.length < msg.variants.length) {
bodies.push(segments[segments.length - 1]?.body ?? "");
}

const skeleton = stringifyMessage(
msg as unknown as Parameters<typeof stringifyMessage>[0]
);
let i = 0;
return skeleton.replace(/\{\{\}\}/g, () => `{{${bodies[i++] ?? ""}}}`);
}

/** Compare two MF2 strings by parsing and re-stringifying both (for tests). */
Expand Down
2 changes: 1 addition & 1 deletion src/specs/import-command.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ Unit `type` values `msg:NONE` / `msg:MF1` / `msg:MF2` (or bare format tokens) ar
- If there is a `translate` attribute and it is set to `no`, set the `dnt` property of the object to `true`.
- If there are any `notes` associated with the `unit`, extract them to `MsgNote` objects using the uppercased category as the note `type`
- Iterate through each `segment` in the `unit` and get the text for the segment translation for the `target` object.
- Reconstruct the complete translated `value` from the collected segments, according to the xliff 2.0 specification rules.
- Reconstruct the complete translated `value` from the collected segments, according to the xliff 2.0 specification rules. Treat segment text as already-decoded; do not pre-escape `\` before JSON serialization. For PGS units, splice segment bodies into the reconstructed message without re-parsing them as MF2 (so `\n`, `\t`, `\{`, and `\\` survive).
- Use the `unit` element's `name` and the complete translated `value` for the unit, together with the `MsgAttribute` object and `MsgNote` array, to programmatically add a new message to the `MsgResource` created earlier.
- Use MsgResource.toJSON(true) to get a serialized JSON string without notes.
- Create a directory named after the project name inside `l10n/translations`, if it does not already exist
Expand Down
36 changes: 36 additions & 0 deletions src/tests/format-xliff.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,4 +177,40 @@ one {{One item}}
expect(msg.value).toMatch(/\.match/);
expect(msg.value).toContain("One item");
});

test("backslash sequences survive export→import JSON round-trip", () => {
const expected = "Hello \\{name\\} tab:\\t nl:\\n slash:\\\\";
const project = createProject("escApp", { format: "NONE" });
const resource = MsgResource.create(
{
title: "R",
attributes: { lang: "en", dir: "ltr", dnt: false },
messages: [{ key: "esc", value: expected, attributes: { format: "NONE" } }],
},
project
);

const exported = serializeResourceGroupsToXliff([
{ project: "escApp", resources: [resource] },
])[0]!.xliff;
expect(exported).toContain("\\{name\\}");

const bilingual = toBilingualXliff(exported, "zh");
const parsed = parseXliff20(bilingual);
const xliffRoot = (parsed as Record<string, unknown>).xliff as Record<
string,
unknown
>;
const fileEl = (
Array.isArray(xliffRoot.file) ? xliffRoot.file[0] : xliffRoot.file
) as Record<string, unknown>;

const imported = extractResourceFromXliffFile(fileEl, "zh", project, ["zh"]);
expect(imported).not.toBeNull();
expect(imported!.get("esc")?.value).toBe(expected);
const json = JSON.parse(imported!.toJSON(true)) as {
messages: { value: string }[];
};
expect(json.messages[0]!.value).toBe(expected);
});
});
121 changes: 121 additions & 0 deletions src/tests/import-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,127 @@ one {{一}}
expect(result!.getData(true).messages![0].value).toBe("你好");
});

test("preserves backslash sequences from XLIFF target through JSON.parse", () => {
const expected = "Hello \\{name\\} tab:\\t nl:\\n slash:\\\\";
const fileEl = {
"@_original": "R.json",
"@_trgLang": "zh",
unit: {
"@_id": "u1",
"@_name": "esc",
segment: { source: "x", target: expected },
},
};
const result = extractResourceFromXliffFile(
fileEl as unknown as Record<string, unknown>,
"zh",
project,
["zh"]
);
expect(result!.get("esc")?.value).toBe(expected);
const parsed = JSON.parse(result!.toJSON(true)) as {
messages: { value: string }[];
};
expect(parsed.messages[0]!.value).toBe(expected);
});

test("preserves backslash sequences when parsing real XLIFF XML", () => {
const expected = "Hello \\{name\\} tab:\\t nl:\\n slash:\\\\";
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.2" version="2.2" srcLang="en" trgLang="zh">
<file id="f1" original="R.json">
<unit id="u1" name="esc">
<segment>
<source>x</source>
<target>${expected}</target>
</segment>
</unit>
</file>
</xliff>`;
const parsedXml = parseXliff20(xml) as Record<string, unknown>;
const xliffRoot = parsedXml.xliff as Record<string, unknown>;
const fileEl = (
Array.isArray(xliffRoot.file) ? xliffRoot.file[0] : xliffRoot.file
) as Record<string, unknown>;
const result = extractResourceFromXliffFile(fileEl, "zh", project, ["zh"]);
expect(result!.get("esc")?.value).toBe(expected);
const parsed = JSON.parse(result!.toJSON(true)) as {
messages: { value: string }[];
};
expect(parsed.messages[0]!.value).toBe(expected);
});

test("preserves backslash sequences in PGS MF2 segment bodies", () => {
const body = "Hello \\{name\\}";
const fileEl = {
"@_original": "R.json",
"@_trgLang": "zh",
unit: {
"@_id": "u1",
"@_name": "items",
"@_type": "msg:MF2",
"@_pgs:switch": "plural:n",
segment: [
{
"@_pgs:case": "one",
source: "one",
target: body,
},
{
"@_pgs:case": "other",
source: "other",
target: body,
},
],
},
};
const result = extractResourceFromXliffFile(
fileEl as unknown as Record<string, unknown>,
"zh",
project,
["zh"]
);
const value = result!.get("items")?.value ?? "";
expect(value).toContain("\\{name\\}");
const parsed = JSON.parse(result!.toJSON(true)) as {
messages: { value: string }[];
};
expect(parsed.messages[0]!.value).toContain("\\{name\\}");
expect(parsed.messages[0]!.value).not.toContain("\\\\{name\\\\}");
});

test("preserves backslash sequences in PGS MF1 segment bodies", () => {
const body = "Hello \\{name\\}";
const fileEl = {
"@_original": "R.json",
"@_trgLang": "zh",
unit: {
"@_id": "u1",
"@_name": "items",
"@_type": "msg:MF1",
"@_pgs:switch": "plural:count",
segment: [
{ "@_pgs:case": "one", source: "one", target: body },
{ "@_pgs:case": "other", source: "other", target: body },
],
},
};
const result = extractResourceFromXliffFile(
fileEl as unknown as Record<string, unknown>,
"zh",
project,
["zh"]
);
const value = result!.get("items")?.value ?? "";
// MF1 rebuild parses MF2-style `\{` as a literal brace and ICU-quotes it.
expect(value).toContain("Hello '{'name'}'");
const parsed = JSON.parse(result!.toJSON(true)) as {
messages: { value: string }[];
};
expect(parsed.messages[0]!.value).toContain("Hello '{'name'}'");
expect(parsed.messages[0]!.value).not.toContain("\\\\{");
});

test("extracts segment with target as object (inline elements)", () => {
const fileEl = {
"@_original": "I.json",
Expand Down
14 changes: 14 additions & 0 deletions src/tests/pgs-mf1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,18 @@ describe("pgs-mf1", () => {
expect(back).toMatch(/\{g,\s*select,/);
expect(back).toMatch(/\{n,\s*plural,/);
});

test("import preserves backslash sequences in segment bodies through JSON.parse", () => {
const body = "tab:\\t nl:\\n slash:\\\\";
const back = pgsImportToMf1Message("plural:n", [
{ caseAttr: "one", body },
{ caseAttr: "other", body },
]);
expect(back).not.toBeNull();
expect(back).toContain("\\t");
expect(back).toContain("\\n");
expect(back).toContain("\\\\");
const parsed = JSON.parse(JSON.stringify({ value: back })) as { value: string };
expect(parsed.value).toBe(back);
});
});
15 changes: 15 additions & 0 deletions src/tests/pgs-mf2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,19 @@ masculine {{His party}}
"other",
]);
});

test("import preserves backslash sequences in segment bodies", () => {
const body = "Hello \\{name\\} tab:\\t nl:\\n slash:\\\\";
const back = pgsImportToSelectMessage("plural:n", [
{ caseAttr: "one", body },
{ caseAttr: "other", body },
]);
expect(back).not.toBeNull();
expect(back).toContain("\\{name\\}");
expect(back).toContain("\\t");
expect(back).toContain("\\n");
const parsed = JSON.parse(JSON.stringify({ value: back })) as { value: string };
expect(parsed.value).toBe(back);
expect(parsed.value).not.toContain("\\\\{name\\\\}");
});
});
Loading