Skip to content

Commit

Permalink
Remember user's action to break strings into different translations (#…
Browse files Browse the repository at this point in the history
…1694)

This is achieved by trying to use the saved resource name whenever
possible. No overriding by group options.
    
Fixes: #1677
  • Loading branch information
yiqunw700 authored Nov 13, 2024
1 parent e8a1d36 commit 872862b
Show file tree
Hide file tree
Showing 2 changed files with 237 additions and 60 deletions.
263 changes: 219 additions & 44 deletions support-figma/extended-layout-plugin/src/localization-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import * as DesignSpecs from "./design-spec-module";
const STRING_RES_PLUGIN_DATA_KEY = "vsw-string-res";
const STRING_RES_EXTRAS_PLUGIN_DATA_KEY = "vsw-string-res-extras";
const EXPLICIT_EXCLUSION_PLUGIN_DATA_KEY = "vsw-string-explicit-exclusion";
// This saves the characters of the text nodes. If it changes, the res name can become invalid.
const STRING_RES_CHARACTERS_PLUGIN_DATA_KEY = "vsw-string-res-characters";
const CONSOLE_TAG = `${Utils.CONSOLE_TAG}-LOCALIZATION`;
const OPTION_EXCLUDE_HASHTAG_NAME = "excludeHashTagName";
const OPTION_READ_CUSTOMIZATION = "readJsonCustomization";
Expand Down Expand Up @@ -56,22 +58,29 @@ export async function generateLocalizationData(
options.includes(OPTION_READ_CUSTOMIZATION)
);

// String resource name to StringResource map.
let stringResourceMap = new Map<string, StringResource>();

let outputStringResMap = new Map<string, StringResource>();
// strings.xml files does not allow duplicates so no checks for duplicates here.
for (let uploadedString of uploadedStrings) {
let strRes = uploadedString as unknown as StringResource;
stringResourceMap.set(strRes.name, strRes);
outputStringResMap.set(strRes.name, strRes);
}

// String resource name to StringResource map.
let stringResourceMap = new Map<string, StringResource>();
// Text nodes that have changed text or text styles.
let staleTextNodes = new Array<TextNode>();
// Text nodes that have not been assigned with a res name before.
let newTextNodes = new Array<TextNode>();

if (clippyTextNodes.topLevelComponentIds.length === 0) {
// No clippy top level components found in customization file. Localize all the nodes recursively from root.
for (let page of figma.root.children) {
for (let child of page.children) {
await localizeNodeAsync(
child,
stringResourceMap,
staleTextNodes,
newTextNodes,
options,
clippyTextNodes["customizedTextNodeArray"]
);
Expand All @@ -85,6 +94,8 @@ export async function generateLocalizationData(
await localizeNodeAsync(
node,
stringResourceMap,
staleTextNodes,
newTextNodes,
options,
clippyTextNodes["customizedTextNodeArray"]
);
Expand All @@ -99,18 +110,36 @@ export async function generateLocalizationData(
await localizeNodeAsync(
localComponent,
stringResourceMap,
staleTextNodes,
newTextNodes,
options,
clippyTextNodes["customizedTextNodeArray"]
);
}
}
}

for (const textNode of staleTextNodes) {
await localizeStaleTextNodeAsync(textNode, stringResourceMap, options);
}

for (const textNode of newTextNodes) {
await localizeNewTextNodeAsync(
textNode,
stringResourceMap,
options,
undefined,
false
);
}

await mergeStringResMaps(outputStringResMap, stringResourceMap, options);

// Convert to an array of key-value pairs
const stringResourceArray = Array.from(stringResourceMap);
const outputStringResArray = Array.from(outputStringResMap);
figma.ui.postMessage({
msg: "localization-output",
output: stringResourceArray,
output: outputStringResArray,
});
}

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

saveResName(node, stringResName, isNodeExcluded);
Expand Down Expand Up @@ -231,10 +260,12 @@ export async function excludeTextNode(nodeId: string, excluded: boolean) {
async function localizeNodeAsync(
node: BaseNode,
stringResourceMap: Map<string, StringResource>,
staleTextNodes: Array<TextNode>,
newTextNodes: Array<TextNode>,
options: string[],
clippyTextNodes: BaseNode[]
) {
if (node.type == "TEXT") {
if (node.type === "TEXT") {
// Nodes with name starting with # is for local customization.
if (
options.includes(OPTION_EXCLUDE_HASHTAG_NAME) &&
Expand All @@ -244,7 +275,12 @@ async function localizeNodeAsync(
} else if (clippyTextNodes.includes(node)) {
Utils.log(CONSOLE_TAG, "Ignore client side customization:", node.name);
} else {
await localizeTextNodeAsync(node, stringResourceMap, options);
await localizeTextNodeAsync(
node,
stringResourceMap,
staleTextNodes,
newTextNodes
);
}
} else {
// Recurse into any children.
Expand All @@ -254,6 +290,8 @@ async function localizeNodeAsync(
await localizeNodeAsync(
child,
stringResourceMap,
staleTextNodes,
newTextNodes,
options,
clippyTextNodes
);
Expand All @@ -263,65 +301,197 @@ async function localizeNodeAsync(
}

async function localizeTextNodeAsync(
node: TextNode,
stringResourceMap: Map<string, StringResource>,
staleTextNodes: Array<TextNode>,
newTextNodes: Array<TextNode>
) {
var preferredName = getResName(node);
if (preferredName) {
let normalizedText = normalizeTextNode(node);
const isNodeExcluded = isExplicitlyExcluded(node);
var cachedCharacters = node.getPluginData(
STRING_RES_CHARACTERS_PLUGIN_DATA_KEY
);
// This node has been exported as string resource before and it hasn't changed,
// use its res name and put in the string res map.
if (cachedCharacters === JSON.stringify(normalizedText)) {
let existingStringRes = stringResourceMap.get(preferredName);
if (!existingStringRes) {
const stringRes = {
name: preferredName,
translatable: true,
text: normalizedText,
textNodes: [{ nodeId: node.id, isExcluded: isNodeExcluded }],
extras: getSavedExtras(node),
textLength: node.characters.length,
};
stringResourceMap.set(preferredName, stringRes);
} else {
existingStringRes.textNodes.push({
nodeId: node.id,
isExcluded: isNodeExcluded,
});
}
} else {
staleTextNodes.push(node);
}
} else {
newTextNodes.push(node);
}
}

async function localizeStaleTextNodeAsync(
node: TextNode,
stringResourceMap: Map<string, StringResource>,
options: string[]
) {
let normalizedText = normalizeTextNode(node);
const isNodeExcluded = isExplicitlyExcluded(node);
var preferredName = getResName(node);
// Text node has res name, but the text or text style has changed. Try to use the saved res name first.
if (preferredName) {
if (!stringResourceMap.has(preferredName)) {
let normalizedText = normalizeTextNode(node);
saveCharacters(node, JSON.stringify(normalizedText));

const stringRes = {
name: preferredName,
translatable: true,
text: normalizedText,
textNodes: [{ nodeId: node.id, isExcluded: isNodeExcluded }],
extras: getSavedExtras(node),
textLength: node.characters.length,
};
stringResourceMap.set(preferredName, stringRes);
return;
}
// Treat it as a new text node to assign a string res name.
localizeNewTextNodeAsync(
node,
stringResourceMap,
options,
preferredName,
isNodeExcluded
);
} else {
Utils.error(CONSOLE_TAG, `Node ${node.id} expects a saved res name.`);
}
}

// This node is new to export as string resource. It does not have a res name saved before.
async function localizeNewTextNodeAsync(
node: TextNode,
stringResourceMap: Map<string, StringResource>,
options: string[],
preferredName: string | undefined,
isNodeExcluded: boolean
) {
let normalizedText = normalizeTextNode(node);
saveCharacters(node, JSON.stringify(normalizedText));

var isMatched = false;
if (options.includes(OPTION_GROUP_SAME_TEXT)) {
// First find and tag. It will override the existing string resource name from the string resource entry read from file.
// Find and tag if option is to group the same text.
const containedValue = [...stringResourceMap.values()].filter(
(value) => textMatches(value, normalizedText) && value.translatable
);
// Pick the first match...
if (containedValue.length > 0) {
if (!containedValue[0].textNodes) {
containedValue[0].textNodes = [];
}
containedValue[0].textNodes.push({
nodeId: node.id,
isExcluded: isNodeExcluded,
});
Utils.log(CONSOLE_TAG, "Found and tag:", containedValue[0].name);
saveResName(node, containedValue[0].name, isNodeExcluded);
saveExtras(node, containedValue[0].extras);
// Set the text length to set a proper char limit range.
containedValue[0].textLength = node.characters.length;
return;
isMatched = true;
}
}

var preferredName = getResName(node);
if (!preferredName) {
preferredName = fromNode(node);
} else if (
stringResourceMap.has(preferredName) &&
endsWithNumbers(preferredName)
) {
// We need to find a new name so reset preferred name to default.
preferredName = fromNode(node);
if (!isMatched) {
if (!preferredName || endsWithNumbers(preferredName)) {
preferredName = fromNode(node);
}
var stringResName = preferredName;
var index = 0;

// Otherwise find a string resource name that doesn't duplicate.
while (stringResourceMap.has(stringResName)) {
index += 1;
stringResName = preferredName + "_" + index;
}
saveResName(node, stringResName, isNodeExcluded);

var stringRes = {
name: stringResName,
translatable: true,
text: normalizedText,
textNodes: [{ nodeId: node.id, isExcluded: isNodeExcluded }],
extras: getSavedExtras(node),
textLength: node.characters.length,
};
stringResourceMap.set(stringResName, stringRes);
}
}

var index = 0;
var stringResName = preferredName;
// The outputStringResMap has the uploaded strings and toBeMergedStringResMap has the strings from
// current figma file only. Merge toBeMergedStringResMap into the outputStringResMap.
async function mergeStringResMaps(
outputStringResMap: Map<string, StringResource>,
toBeMergedStringResMap: Map<string, StringResource>,
options: string[]
) {
for (const [resName, stringRes] of toBeMergedStringResMap) {
if (outputStringResMap.has(resName)) {
if (textMatches(stringRes, outputStringResMap.get(resName)!!.text)) {
outputStringResMap.set(resName, stringRes);
continue;
}
}

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

saveResName(node, stringResName, isNodeExcluded);

var stringRes = {
name: stringResName,
translatable: true,
text: normalizedText,
textNodes: [{ nodeId: node.id, isExcluded: isNodeExcluded }],
extras: getSavedExtras(node),
textLength: node.characters.length,
};
stringResourceMap.set(stringResName, stringRes);
// There is exactly 1:1 match.
if (containedValue.length == 1) {
const duplicates = [...toBeMergedStringResMap.values()].filter(
(value) => textMatches(value, stringRes.text)
);
if (duplicates.length == 1) {
stringRes.name = containedValue[0].name;
await updateStringRes(stringRes);
outputStringResMap.set(stringRes.name, stringRes);
continue;
}
}
}
if (outputStringResMap.has(resName)) {
// Rename the string Res and put it to the output string res map.
var preferredName = resName;
if (endsWithNumbers(preferredName)) {
var node = await figma.getNodeByIdAsync(stringRes.textNodes[0].nodeId);
if (node && node.type === "TEXT") {
preferredName = fromNode(node);
}
}
var newResName = preferredName;
var index = 0;
while (
outputStringResMap.has(newResName) ||
toBeMergedStringResMap.has(newResName)
) {
index += 1;
newResName = `${preferredName}_${index}`;
}
stringRes.name = newResName;
await updateStringRes(stringRes);
outputStringResMap.set(newResName, stringRes);
} else {
outputStringResMap.set(resName, stringRes);
}
}
}

function fromNode(node: TextNode): string {
Expand Down Expand Up @@ -401,6 +571,10 @@ function saveExtras(node: TextNode, extras?: StringResourceExtras) {
}
}

function saveCharacters(node: TextNode, characters: string) {
node.setPluginData(STRING_RES_CHARACTERS_PLUGIN_DATA_KEY, characters);
}

function setExplicitExcluded(node: TextNode, excluded: boolean) {
if (excluded) {
node.setPluginData(EXPLICIT_EXCLUSION_PLUGIN_DATA_KEY, "true");
Expand Down Expand Up @@ -534,6 +708,7 @@ async function clearNodeAsync(node: SceneNode) {
saveResName(node, "", false);
saveExtras(node, undefined);
setExplicitExcluded(node, false);
saveCharacters(node, "");
} else {
// Recurse into any children.
let maybeParent = node as ChildrenMixin;
Expand Down
Loading

0 comments on commit 872862b

Please sign in to comment.