@@ -20,6 +20,8 @@ import * as DesignSpecs from "./design-spec-module";
20
20
const STRING_RES_PLUGIN_DATA_KEY = "vsw-string-res" ;
21
21
const STRING_RES_EXTRAS_PLUGIN_DATA_KEY = "vsw-string-res-extras" ;
22
22
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" ;
23
25
const CONSOLE_TAG = `${ Utils . CONSOLE_TAG } -LOCALIZATION` ;
24
26
const OPTION_EXCLUDE_HASHTAG_NAME = "excludeHashTagName" ;
25
27
const OPTION_READ_CUSTOMIZATION = "readJsonCustomization" ;
@@ -56,22 +58,29 @@ export async function generateLocalizationData(
56
58
options . includes ( OPTION_READ_CUSTOMIZATION )
57
59
) ;
58
60
59
- // String resource name to StringResource map.
60
- let stringResourceMap = new Map < string , StringResource > ( ) ;
61
-
61
+ let outputStringResMap = new Map < string , StringResource > ( ) ;
62
62
// strings.xml files does not allow duplicates so no checks for duplicates here.
63
63
for ( let uploadedString of uploadedStrings ) {
64
64
let strRes = uploadedString as unknown as StringResource ;
65
- stringResourceMap . set ( strRes . name , strRes ) ;
65
+ outputStringResMap . set ( strRes . name , strRes ) ;
66
66
}
67
67
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
+
68
75
if ( clippyTextNodes . topLevelComponentIds . length === 0 ) {
69
76
// No clippy top level components found in customization file. Localize all the nodes recursively from root.
70
77
for ( let page of figma . root . children ) {
71
78
for ( let child of page . children ) {
72
79
await localizeNodeAsync (
73
80
child ,
74
81
stringResourceMap ,
82
+ staleTextNodes ,
83
+ newTextNodes ,
75
84
options ,
76
85
clippyTextNodes [ "customizedTextNodeArray" ]
77
86
) ;
@@ -85,6 +94,8 @@ export async function generateLocalizationData(
85
94
await localizeNodeAsync (
86
95
node ,
87
96
stringResourceMap ,
97
+ staleTextNodes ,
98
+ newTextNodes ,
88
99
options ,
89
100
clippyTextNodes [ "customizedTextNodeArray" ]
90
101
) ;
@@ -99,18 +110,36 @@ export async function generateLocalizationData(
99
110
await localizeNodeAsync (
100
111
localComponent ,
101
112
stringResourceMap ,
113
+ staleTextNodes ,
114
+ newTextNodes ,
102
115
options ,
103
116
clippyTextNodes [ "customizedTextNodeArray" ]
104
117
) ;
105
118
}
106
119
}
107
120
}
108
121
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
+
109
138
// Convert to an array of key-value pairs
110
- const stringResourceArray = Array . from ( stringResourceMap ) ;
139
+ const outputStringResArray = Array . from ( outputStringResMap ) ;
111
140
figma . ui . postMessage ( {
112
141
msg : "localization-output" ,
113
- output : stringResourceArray ,
142
+ output : outputStringResArray ,
114
143
} ) ;
115
144
}
116
145
@@ -171,7 +200,7 @@ export async function ungroupTextNode(
171
200
// Otherwise find a string resource name that doesn't duplicate.
172
201
while ( stringResourceMap . has ( stringResName ) ) {
173
202
index += 1 ;
174
- stringResName = preferredName + "_" + index ;
203
+ stringResName = ` ${ preferredName } _ ${ index } ` ;
175
204
}
176
205
177
206
saveResName ( node , stringResName , isNodeExcluded ) ;
@@ -231,10 +260,12 @@ export async function excludeTextNode(nodeId: string, excluded: boolean) {
231
260
async function localizeNodeAsync (
232
261
node : BaseNode ,
233
262
stringResourceMap : Map < string , StringResource > ,
263
+ staleTextNodes : Array < TextNode > ,
264
+ newTextNodes : Array < TextNode > ,
234
265
options : string [ ] ,
235
266
clippyTextNodes : BaseNode [ ]
236
267
) {
237
- if ( node . type == "TEXT" ) {
268
+ if ( node . type === "TEXT" ) {
238
269
// Nodes with name starting with # is for local customization.
239
270
if (
240
271
options . includes ( OPTION_EXCLUDE_HASHTAG_NAME ) &&
@@ -244,7 +275,12 @@ async function localizeNodeAsync(
244
275
} else if ( clippyTextNodes . includes ( node ) ) {
245
276
Utils . log ( CONSOLE_TAG , "Ignore client side customization:" , node . name ) ;
246
277
} else {
247
- await localizeTextNodeAsync ( node , stringResourceMap , options ) ;
278
+ await localizeTextNodeAsync (
279
+ node ,
280
+ stringResourceMap ,
281
+ staleTextNodes ,
282
+ newTextNodes
283
+ ) ;
248
284
}
249
285
} else {
250
286
// Recurse into any children.
@@ -254,6 +290,8 @@ async function localizeNodeAsync(
254
290
await localizeNodeAsync (
255
291
child ,
256
292
stringResourceMap ,
293
+ staleTextNodes ,
294
+ newTextNodes ,
257
295
options ,
258
296
clippyTextNodes
259
297
) ;
@@ -263,65 +301,197 @@ async function localizeNodeAsync(
263
301
}
264
302
265
303
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 (
266
345
node : TextNode ,
267
346
stringResourceMap : Map < string , StringResource > ,
268
347
options : string [ ]
269
348
) {
270
- let normalizedText = normalizeTextNode ( node ) ;
271
349
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 ;
272
393
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 .
274
395
const containedValue = [ ...stringResourceMap . values ( ) ] . filter (
275
396
( value ) => textMatches ( value , normalizedText ) && value . translatable
276
397
) ;
398
+ // Pick the first match...
277
399
if ( containedValue . length > 0 ) {
278
- if ( ! containedValue [ 0 ] . textNodes ) {
279
- containedValue [ 0 ] . textNodes = [ ] ;
280
- }
281
400
containedValue [ 0 ] . textNodes . push ( {
282
401
nodeId : node . id ,
283
402
isExcluded : isNodeExcluded ,
284
403
} ) ;
285
404
Utils . log ( CONSOLE_TAG , "Found and tag:" , containedValue [ 0 ] . name ) ;
286
405
saveResName ( node , containedValue [ 0 ] . name , isNodeExcluded ) ;
287
406
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 ;
291
408
}
292
409
}
293
410
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 ) ;
303
434
}
435
+ }
304
436
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
+ }
307
451
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
+ ) ;
313
456
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
+ }
325
495
}
326
496
327
497
function fromNode ( node : TextNode ) : string {
@@ -401,6 +571,10 @@ function saveExtras(node: TextNode, extras?: StringResourceExtras) {
401
571
}
402
572
}
403
573
574
+ function saveCharacters ( node : TextNode , characters : string ) {
575
+ node . setPluginData ( STRING_RES_CHARACTERS_PLUGIN_DATA_KEY , characters ) ;
576
+ }
577
+
404
578
function setExplicitExcluded ( node : TextNode , excluded : boolean ) {
405
579
if ( excluded ) {
406
580
node . setPluginData ( EXPLICIT_EXCLUSION_PLUGIN_DATA_KEY , "true" ) ;
@@ -534,6 +708,7 @@ async function clearNodeAsync(node: SceneNode) {
534
708
saveResName ( node , "" , false ) ;
535
709
saveExtras ( node , undefined ) ;
536
710
setExplicitExcluded ( node , false ) ;
711
+ saveCharacters ( node , "" ) ;
537
712
} else {
538
713
// Recurse into any children.
539
714
let maybeParent = node as ChildrenMixin ;
0 commit comments