Skip to content

Commit 872862b

Browse files
authored
Remember user's action to break strings into different translations (#1694)
This is achieved by trying to use the saved resource name whenever possible. No overriding by group options. Fixes: #1677
1 parent e8a1d36 commit 872862b

File tree

2 files changed

+237
-60
lines changed

2 files changed

+237
-60
lines changed

support-figma/extended-layout-plugin/src/localization-module.ts

Lines changed: 219 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import * as DesignSpecs from "./design-spec-module";
2020
const STRING_RES_PLUGIN_DATA_KEY = "vsw-string-res";
2121
const STRING_RES_EXTRAS_PLUGIN_DATA_KEY = "vsw-string-res-extras";
2222
const EXPLICIT_EXCLUSION_PLUGIN_DATA_KEY = "vsw-string-explicit-exclusion";
23+
// This saves the characters of the text nodes. If it changes, the res name can become invalid.
24+
const STRING_RES_CHARACTERS_PLUGIN_DATA_KEY = "vsw-string-res-characters";
2325
const CONSOLE_TAG = `${Utils.CONSOLE_TAG}-LOCALIZATION`;
2426
const OPTION_EXCLUDE_HASHTAG_NAME = "excludeHashTagName";
2527
const OPTION_READ_CUSTOMIZATION = "readJsonCustomization";
@@ -56,22 +58,29 @@ export async function generateLocalizationData(
5658
options.includes(OPTION_READ_CUSTOMIZATION)
5759
);
5860

59-
// String resource name to StringResource map.
60-
let stringResourceMap = new Map<string, StringResource>();
61-
61+
let outputStringResMap = new Map<string, StringResource>();
6262
// strings.xml files does not allow duplicates so no checks for duplicates here.
6363
for (let uploadedString of uploadedStrings) {
6464
let strRes = uploadedString as unknown as StringResource;
65-
stringResourceMap.set(strRes.name, strRes);
65+
outputStringResMap.set(strRes.name, strRes);
6666
}
6767

68+
// String resource name to StringResource map.
69+
let stringResourceMap = new Map<string, StringResource>();
70+
// Text nodes that have changed text or text styles.
71+
let staleTextNodes = new Array<TextNode>();
72+
// Text nodes that have not been assigned with a res name before.
73+
let newTextNodes = new Array<TextNode>();
74+
6875
if (clippyTextNodes.topLevelComponentIds.length === 0) {
6976
// No clippy top level components found in customization file. Localize all the nodes recursively from root.
7077
for (let page of figma.root.children) {
7178
for (let child of page.children) {
7279
await localizeNodeAsync(
7380
child,
7481
stringResourceMap,
82+
staleTextNodes,
83+
newTextNodes,
7584
options,
7685
clippyTextNodes["customizedTextNodeArray"]
7786
);
@@ -85,6 +94,8 @@ export async function generateLocalizationData(
8594
await localizeNodeAsync(
8695
node,
8796
stringResourceMap,
97+
staleTextNodes,
98+
newTextNodes,
8899
options,
89100
clippyTextNodes["customizedTextNodeArray"]
90101
);
@@ -99,18 +110,36 @@ export async function generateLocalizationData(
99110
await localizeNodeAsync(
100111
localComponent,
101112
stringResourceMap,
113+
staleTextNodes,
114+
newTextNodes,
102115
options,
103116
clippyTextNodes["customizedTextNodeArray"]
104117
);
105118
}
106119
}
107120
}
108121

122+
for (const textNode of staleTextNodes) {
123+
await localizeStaleTextNodeAsync(textNode, stringResourceMap, options);
124+
}
125+
126+
for (const textNode of newTextNodes) {
127+
await localizeNewTextNodeAsync(
128+
textNode,
129+
stringResourceMap,
130+
options,
131+
undefined,
132+
false
133+
);
134+
}
135+
136+
await mergeStringResMaps(outputStringResMap, stringResourceMap, options);
137+
109138
// Convert to an array of key-value pairs
110-
const stringResourceArray = Array.from(stringResourceMap);
139+
const outputStringResArray = Array.from(outputStringResMap);
111140
figma.ui.postMessage({
112141
msg: "localization-output",
113-
output: stringResourceArray,
142+
output: outputStringResArray,
114143
});
115144
}
116145

@@ -171,7 +200,7 @@ export async function ungroupTextNode(
171200
// Otherwise find a string resource name that doesn't duplicate.
172201
while (stringResourceMap.has(stringResName)) {
173202
index += 1;
174-
stringResName = preferredName + "_" + index;
203+
stringResName = `${preferredName}_${index}`;
175204
}
176205

177206
saveResName(node, stringResName, isNodeExcluded);
@@ -231,10 +260,12 @@ export async function excludeTextNode(nodeId: string, excluded: boolean) {
231260
async function localizeNodeAsync(
232261
node: BaseNode,
233262
stringResourceMap: Map<string, StringResource>,
263+
staleTextNodes: Array<TextNode>,
264+
newTextNodes: Array<TextNode>,
234265
options: string[],
235266
clippyTextNodes: BaseNode[]
236267
) {
237-
if (node.type == "TEXT") {
268+
if (node.type === "TEXT") {
238269
// Nodes with name starting with # is for local customization.
239270
if (
240271
options.includes(OPTION_EXCLUDE_HASHTAG_NAME) &&
@@ -244,7 +275,12 @@ async function localizeNodeAsync(
244275
} else if (clippyTextNodes.includes(node)) {
245276
Utils.log(CONSOLE_TAG, "Ignore client side customization:", node.name);
246277
} else {
247-
await localizeTextNodeAsync(node, stringResourceMap, options);
278+
await localizeTextNodeAsync(
279+
node,
280+
stringResourceMap,
281+
staleTextNodes,
282+
newTextNodes
283+
);
248284
}
249285
} else {
250286
// Recurse into any children.
@@ -254,6 +290,8 @@ async function localizeNodeAsync(
254290
await localizeNodeAsync(
255291
child,
256292
stringResourceMap,
293+
staleTextNodes,
294+
newTextNodes,
257295
options,
258296
clippyTextNodes
259297
);
@@ -263,65 +301,197 @@ async function localizeNodeAsync(
263301
}
264302

265303
async function localizeTextNodeAsync(
304+
node: TextNode,
305+
stringResourceMap: Map<string, StringResource>,
306+
staleTextNodes: Array<TextNode>,
307+
newTextNodes: Array<TextNode>
308+
) {
309+
var preferredName = getResName(node);
310+
if (preferredName) {
311+
let normalizedText = normalizeTextNode(node);
312+
const isNodeExcluded = isExplicitlyExcluded(node);
313+
var cachedCharacters = node.getPluginData(
314+
STRING_RES_CHARACTERS_PLUGIN_DATA_KEY
315+
);
316+
// This node has been exported as string resource before and it hasn't changed,
317+
// use its res name and put in the string res map.
318+
if (cachedCharacters === JSON.stringify(normalizedText)) {
319+
let existingStringRes = stringResourceMap.get(preferredName);
320+
if (!existingStringRes) {
321+
const stringRes = {
322+
name: preferredName,
323+
translatable: true,
324+
text: normalizedText,
325+
textNodes: [{ nodeId: node.id, isExcluded: isNodeExcluded }],
326+
extras: getSavedExtras(node),
327+
textLength: node.characters.length,
328+
};
329+
stringResourceMap.set(preferredName, stringRes);
330+
} else {
331+
existingStringRes.textNodes.push({
332+
nodeId: node.id,
333+
isExcluded: isNodeExcluded,
334+
});
335+
}
336+
} else {
337+
staleTextNodes.push(node);
338+
}
339+
} else {
340+
newTextNodes.push(node);
341+
}
342+
}
343+
344+
async function localizeStaleTextNodeAsync(
266345
node: TextNode,
267346
stringResourceMap: Map<string, StringResource>,
268347
options: string[]
269348
) {
270-
let normalizedText = normalizeTextNode(node);
271349
const isNodeExcluded = isExplicitlyExcluded(node);
350+
var preferredName = getResName(node);
351+
// Text node has res name, but the text or text style has changed. Try to use the saved res name first.
352+
if (preferredName) {
353+
if (!stringResourceMap.has(preferredName)) {
354+
let normalizedText = normalizeTextNode(node);
355+
saveCharacters(node, JSON.stringify(normalizedText));
356+
357+
const stringRes = {
358+
name: preferredName,
359+
translatable: true,
360+
text: normalizedText,
361+
textNodes: [{ nodeId: node.id, isExcluded: isNodeExcluded }],
362+
extras: getSavedExtras(node),
363+
textLength: node.characters.length,
364+
};
365+
stringResourceMap.set(preferredName, stringRes);
366+
return;
367+
}
368+
// Treat it as a new text node to assign a string res name.
369+
localizeNewTextNodeAsync(
370+
node,
371+
stringResourceMap,
372+
options,
373+
preferredName,
374+
isNodeExcluded
375+
);
376+
} else {
377+
Utils.error(CONSOLE_TAG, `Node ${node.id} expects a saved res name.`);
378+
}
379+
}
380+
381+
// This node is new to export as string resource. It does not have a res name saved before.
382+
async function localizeNewTextNodeAsync(
383+
node: TextNode,
384+
stringResourceMap: Map<string, StringResource>,
385+
options: string[],
386+
preferredName: string | undefined,
387+
isNodeExcluded: boolean
388+
) {
389+
let normalizedText = normalizeTextNode(node);
390+
saveCharacters(node, JSON.stringify(normalizedText));
391+
392+
var isMatched = false;
272393
if (options.includes(OPTION_GROUP_SAME_TEXT)) {
273-
// First find and tag. It will override the existing string resource name from the string resource entry read from file.
394+
// Find and tag if option is to group the same text.
274395
const containedValue = [...stringResourceMap.values()].filter(
275396
(value) => textMatches(value, normalizedText) && value.translatable
276397
);
398+
// Pick the first match...
277399
if (containedValue.length > 0) {
278-
if (!containedValue[0].textNodes) {
279-
containedValue[0].textNodes = [];
280-
}
281400
containedValue[0].textNodes.push({
282401
nodeId: node.id,
283402
isExcluded: isNodeExcluded,
284403
});
285404
Utils.log(CONSOLE_TAG, "Found and tag:", containedValue[0].name);
286405
saveResName(node, containedValue[0].name, isNodeExcluded);
287406
saveExtras(node, containedValue[0].extras);
288-
// Set the text length to set a proper char limit range.
289-
containedValue[0].textLength = node.characters.length;
290-
return;
407+
isMatched = true;
291408
}
292409
}
293410

294-
var preferredName = getResName(node);
295-
if (!preferredName) {
296-
preferredName = fromNode(node);
297-
} else if (
298-
stringResourceMap.has(preferredName) &&
299-
endsWithNumbers(preferredName)
300-
) {
301-
// We need to find a new name so reset preferred name to default.
302-
preferredName = fromNode(node);
411+
if (!isMatched) {
412+
if (!preferredName || endsWithNumbers(preferredName)) {
413+
preferredName = fromNode(node);
414+
}
415+
var stringResName = preferredName;
416+
var index = 0;
417+
418+
// Otherwise find a string resource name that doesn't duplicate.
419+
while (stringResourceMap.has(stringResName)) {
420+
index += 1;
421+
stringResName = preferredName + "_" + index;
422+
}
423+
saveResName(node, stringResName, isNodeExcluded);
424+
425+
var stringRes = {
426+
name: stringResName,
427+
translatable: true,
428+
text: normalizedText,
429+
textNodes: [{ nodeId: node.id, isExcluded: isNodeExcluded }],
430+
extras: getSavedExtras(node),
431+
textLength: node.characters.length,
432+
};
433+
stringResourceMap.set(stringResName, stringRes);
303434
}
435+
}
304436

305-
var index = 0;
306-
var stringResName = preferredName;
437+
// The outputStringResMap has the uploaded strings and toBeMergedStringResMap has the strings from
438+
// current figma file only. Merge toBeMergedStringResMap into the outputStringResMap.
439+
async function mergeStringResMaps(
440+
outputStringResMap: Map<string, StringResource>,
441+
toBeMergedStringResMap: Map<string, StringResource>,
442+
options: string[]
443+
) {
444+
for (const [resName, stringRes] of toBeMergedStringResMap) {
445+
if (outputStringResMap.has(resName)) {
446+
if (textMatches(stringRes, outputStringResMap.get(resName)!!.text)) {
447+
outputStringResMap.set(resName, stringRes);
448+
continue;
449+
}
450+
}
307451

308-
// Otherwise find a string resource name that doesn't duplicate.
309-
while (stringResourceMap.has(stringResName)) {
310-
index += 1;
311-
stringResName = preferredName + "_" + index;
312-
}
452+
if (options.includes(OPTION_GROUP_SAME_TEXT)) {
453+
const containedValue = [...outputStringResMap.values()].filter(
454+
(value) => textMatches(value, stringRes.text) && value.translatable
455+
);
313456

314-
saveResName(node, stringResName, isNodeExcluded);
315-
316-
var stringRes = {
317-
name: stringResName,
318-
translatable: true,
319-
text: normalizedText,
320-
textNodes: [{ nodeId: node.id, isExcluded: isNodeExcluded }],
321-
extras: getSavedExtras(node),
322-
textLength: node.characters.length,
323-
};
324-
stringResourceMap.set(stringResName, stringRes);
457+
// There is exactly 1:1 match.
458+
if (containedValue.length == 1) {
459+
const duplicates = [...toBeMergedStringResMap.values()].filter(
460+
(value) => textMatches(value, stringRes.text)
461+
);
462+
if (duplicates.length == 1) {
463+
stringRes.name = containedValue[0].name;
464+
await updateStringRes(stringRes);
465+
outputStringResMap.set(stringRes.name, stringRes);
466+
continue;
467+
}
468+
}
469+
}
470+
if (outputStringResMap.has(resName)) {
471+
// Rename the string Res and put it to the output string res map.
472+
var preferredName = resName;
473+
if (endsWithNumbers(preferredName)) {
474+
var node = await figma.getNodeByIdAsync(stringRes.textNodes[0].nodeId);
475+
if (node && node.type === "TEXT") {
476+
preferredName = fromNode(node);
477+
}
478+
}
479+
var newResName = preferredName;
480+
var index = 0;
481+
while (
482+
outputStringResMap.has(newResName) ||
483+
toBeMergedStringResMap.has(newResName)
484+
) {
485+
index += 1;
486+
newResName = `${preferredName}_${index}`;
487+
}
488+
stringRes.name = newResName;
489+
await updateStringRes(stringRes);
490+
outputStringResMap.set(newResName, stringRes);
491+
} else {
492+
outputStringResMap.set(resName, stringRes);
493+
}
494+
}
325495
}
326496

327497
function fromNode(node: TextNode): string {
@@ -401,6 +571,10 @@ function saveExtras(node: TextNode, extras?: StringResourceExtras) {
401571
}
402572
}
403573

574+
function saveCharacters(node: TextNode, characters: string) {
575+
node.setPluginData(STRING_RES_CHARACTERS_PLUGIN_DATA_KEY, characters);
576+
}
577+
404578
function setExplicitExcluded(node: TextNode, excluded: boolean) {
405579
if (excluded) {
406580
node.setPluginData(EXPLICIT_EXCLUSION_PLUGIN_DATA_KEY, "true");
@@ -534,6 +708,7 @@ async function clearNodeAsync(node: SceneNode) {
534708
saveResName(node, "", false);
535709
saveExtras(node, undefined);
536710
setExplicitExcluded(node, false);
711+
saveCharacters(node, "");
537712
} else {
538713
// Recurse into any children.
539714
let maybeParent = node as ChildrenMixin;

0 commit comments

Comments
 (0)