2
0

sync-settings-keys.js 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. /*
  2. * Synchronize keys of settings messages across locales using zh-CN as canonical.
  3. *
  4. * Supports both layouts:
  5. * - legacy: messages/<locale>/settings.json
  6. * - split: messages/<locale>/settings/ (recursive .json files, assembled by messages/<locale>/settings/index.ts)
  7. *
  8. * Behavior:
  9. * - Ensures every locale has exactly the same set of nested keys as canonical (zh-CN)
  10. * - Keeps existing translations where keys exist
  11. * - Fills missing keys with zh-CN text as placeholder
  12. * - Drops extra keys not present in zh-CN (applies consistently to all locales)
  13. */
  14. const fs = require("node:fs");
  15. const path = require("node:path");
  16. const LOCALES = ["en", "ja", "ru", "zh-TW"];
  17. const CANONICAL = "zh-CN";
  18. const DEFAULT_MESSAGES_DIR = path.join(process.cwd(), "messages");
  19. function getMessagesDir(messagesDir) {
  20. return messagesDir || DEFAULT_MESSAGES_DIR;
  21. }
  22. function isObject(v) {
  23. return v && typeof v === "object" && !Array.isArray(v);
  24. }
  25. function flatten(obj, prefix = "") {
  26. const out = {};
  27. for (const [k, v] of Object.entries(obj || {})) {
  28. const key = prefix ? `${prefix}.${k}` : k;
  29. if (isObject(v)) Object.assign(out, flatten(v, key));
  30. else out[key] = v;
  31. }
  32. return out;
  33. }
  34. function mergeWithCanonical(cn, target) {
  35. const result = Array.isArray(cn) ? [] : {};
  36. for (const [k, v] of Object.entries(cn)) {
  37. const tVal = target?.[k];
  38. if (isObject(v)) {
  39. // Canonical expects an object; only descend if target also has an object, else ignore target
  40. const tchild = isObject(tVal) ? tVal : {};
  41. result[k] = mergeWithCanonical(v, tchild);
  42. } else {
  43. // Canonical expects a leaf (string/number/bool/array/null). If target is an object, ignore it.
  44. if (Object.hasOwn(target || {}, k) && !isObject(tVal)) {
  45. result[k] = tVal;
  46. } else {
  47. result[k] = v;
  48. }
  49. }
  50. }
  51. return result;
  52. }
  53. function sortKeysDeep(obj) {
  54. if (!isObject(obj)) return obj;
  55. const out = {};
  56. for (const key of Object.keys(obj).sort()) {
  57. out[key] = sortKeysDeep(obj[key]);
  58. }
  59. return out;
  60. }
  61. function loadJSON(p) {
  62. return JSON.parse(fs.readFileSync(p, "utf8"));
  63. }
  64. function saveJSON(p, data) {
  65. fs.writeFileSync(p, `${JSON.stringify(data, null, 2)}\n`, "utf8");
  66. }
  67. function listJsonFiles(dir) {
  68. const out = [];
  69. const walk = (d) => {
  70. for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
  71. const full = path.join(d, entry.name);
  72. if (entry.isDirectory()) {
  73. walk(full);
  74. continue;
  75. }
  76. if (entry.isFile() && entry.name.endsWith(".json")) out.push(full);
  77. }
  78. };
  79. if (fs.existsSync(dir)) walk(dir);
  80. return out;
  81. }
  82. function getPath(obj, segments) {
  83. let cur = obj;
  84. for (const s of segments) {
  85. if (!isObject(cur) || !Object.hasOwn(cur, s)) return undefined;
  86. cur = cur[s];
  87. }
  88. return cur;
  89. }
  90. function loadSplitSettings(locale, messagesDir) {
  91. const settingsDir = path.join(getMessagesDir(messagesDir), locale, "settings");
  92. if (!fs.existsSync(settingsDir)) return null;
  93. const top = {};
  94. for (const file of fs.readdirSync(settingsDir, { withFileTypes: true })) {
  95. if (file.isDirectory()) continue;
  96. if (!file.name.endsWith(".json")) continue;
  97. const name = file.name.replace(/\.json$/, "");
  98. const v = loadJSON(path.join(settingsDir, file.name));
  99. if (name === "strings") Object.assign(top, v);
  100. else top[name] = v;
  101. }
  102. const providersDir = path.join(settingsDir, "providers");
  103. const providers = {};
  104. if (fs.existsSync(providersDir)) {
  105. for (const file of fs.readdirSync(providersDir, { withFileTypes: true })) {
  106. if (file.isDirectory()) continue;
  107. if (!file.name.endsWith(".json")) continue;
  108. const name = file.name.replace(/\.json$/, "");
  109. const v = loadJSON(path.join(providersDir, file.name));
  110. if (name === "strings") Object.assign(providers, v);
  111. else providers[name] = v;
  112. }
  113. const formDir = path.join(providersDir, "form");
  114. const form = {};
  115. if (fs.existsSync(formDir)) {
  116. for (const file of fs.readdirSync(formDir, { withFileTypes: true })) {
  117. if (file.isDirectory()) continue;
  118. if (!file.name.endsWith(".json")) continue;
  119. const name = file.name.replace(/\.json$/, "");
  120. const v = loadJSON(path.join(formDir, file.name));
  121. if (name === "strings") Object.assign(form, v);
  122. else form[name] = v;
  123. }
  124. }
  125. providers.form = form;
  126. }
  127. top.providers = providers;
  128. return top;
  129. }
  130. function saveSplitSettingsFromCanonical(locale, merged, messagesDir) {
  131. const root = getMessagesDir(messagesDir);
  132. const cnSettingsDir = path.join(root, CANONICAL, "settings");
  133. const targetSettingsDir = path.join(root, locale, "settings");
  134. const cnFiles = listJsonFiles(cnSettingsDir).map((p) => path.relative(cnSettingsDir, p));
  135. for (const rel of cnFiles) {
  136. const cnPath = path.join(cnSettingsDir, rel);
  137. const targetPath = path.join(targetSettingsDir, rel);
  138. const segs = rel.replace(/\.json$/, "").split(path.sep);
  139. // special: strings.json means "spread into parent object"
  140. if (segs[segs.length - 1] === "strings") {
  141. const tmpl = loadJSON(cnPath);
  142. const parentSegs = segs.slice(0, -1);
  143. const parent = parentSegs.length ? getPath(merged, parentSegs) : merged;
  144. const out = {};
  145. for (const k of Object.keys(tmpl)) out[k] = parent?.[k];
  146. fs.mkdirSync(path.dirname(targetPath), { recursive: true });
  147. saveJSON(targetPath, sortKeysDeep(out));
  148. continue;
  149. }
  150. const v = getPath(merged, segs);
  151. fs.mkdirSync(path.dirname(targetPath), { recursive: true });
  152. saveJSON(targetPath, sortKeysDeep(v));
  153. }
  154. }
  155. function ensureSettings(locale, messagesDir) {
  156. const root = getMessagesDir(messagesDir);
  157. const cnSplit = loadSplitSettings(CANONICAL, root);
  158. const useSplit = Boolean(cnSplit);
  159. const cnPath = path.join(root, CANONICAL, "settings.json");
  160. const targetPath = path.join(root, locale, "settings.json");
  161. const cn = useSplit ? cnSplit : loadJSON(cnPath);
  162. const tSplit = loadSplitSettings(locale, root);
  163. const t = useSplit ? (tSplit ?? {}) : loadJSON(targetPath);
  164. const merged = mergeWithCanonical(cn, t);
  165. // Drop extras implicitly by not copying unknown keys; merged contains only canonical keys
  166. const sorted = sortKeysDeep(merged);
  167. // Stats
  168. const cnKeys = Object.keys(flatten(cn));
  169. const tKeys = Object.keys(flatten(t));
  170. const mergedKeys = Object.keys(flatten(sorted));
  171. const missingBefore = cnKeys.filter((k) => !tKeys.includes(k));
  172. const extraBefore = tKeys.filter((k) => !cnKeys.includes(k));
  173. const missingAfter = cnKeys.filter((k) => !mergedKeys.includes(k));
  174. const extraAfter = mergedKeys.filter((k) => !cnKeys.includes(k));
  175. if (useSplit) {
  176. saveSplitSettingsFromCanonical(locale, sorted, root);
  177. } else {
  178. saveJSON(targetPath, sorted);
  179. }
  180. return {
  181. locale,
  182. targetPath: useSplit ? path.join(root, locale, "settings") : targetPath,
  183. cnCount: cnKeys.length,
  184. before: { count: tKeys.length, missing: missingBefore.length, extra: extraBefore.length },
  185. after: { count: mergedKeys.length, missing: missingAfter.length, extra: extraAfter.length },
  186. };
  187. }
  188. function main() {
  189. const reports = [];
  190. for (const loc of LOCALES) {
  191. const legacy = path.join(DEFAULT_MESSAGES_DIR, loc, "settings.json");
  192. const split = path.join(DEFAULT_MESSAGES_DIR, loc, "settings");
  193. if (!fs.existsSync(legacy) && !fs.existsSync(split)) {
  194. console.error(`[skip] ${loc} has no settings messages`);
  195. continue;
  196. }
  197. reports.push(ensureSettings(loc, DEFAULT_MESSAGES_DIR));
  198. }
  199. // Print summary
  200. for (const r of reports) {
  201. console.log(
  202. `${r.locale}: cn=${r.cnCount}, before=${r.before.count} (-${r.before.missing} missing, +${r.before.extra} extra), after=${r.after.count} (-${r.after.missing} missing, +${r.after.extra} extra)`
  203. );
  204. }
  205. }
  206. module.exports = {
  207. ensureSettings,
  208. flatten,
  209. loadSplitSettings,
  210. mergeWithCanonical,
  211. sortKeysDeep,
  212. };
  213. if (require.main === module) {
  214. main();
  215. }