transifex.js 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. const fs = require('fs').promises;
  2. const spawn = require('cross-spawn');
  3. const yaml = require('js-yaml');
  4. function delay(time) {
  5. return new Promise(resolve => setTimeout(resolve, time));
  6. }
  7. function defer() {
  8. const deferred = {};
  9. deferred.promise = new Promise((resolve, reject) => {
  10. deferred.resolve = resolve;
  11. deferred.reject = reject;
  12. });
  13. return deferred;
  14. }
  15. function memoize(fn) {
  16. const cache = {};
  17. function wrapped(...args) {
  18. const key = args.toString();
  19. let result = cache[key];
  20. if (!result) {
  21. result = { data: fn(...args) };
  22. cache[key] = result;
  23. }
  24. return result.data;
  25. }
  26. return wrapped;
  27. }
  28. function exec(cmd, args, options) {
  29. return new Promise((resolve, reject) => {
  30. const { stdin, ...rest } = options || {};
  31. const child = spawn(
  32. cmd,
  33. args,
  34. { ...rest, stdio: ['pipe', 'pipe', 'inherit'] },
  35. );
  36. if (stdin != null) {
  37. child.stdin.write(stdin);
  38. child.stdin.end();
  39. }
  40. const stdoutBuffer = [];
  41. child.stdout.on('data', chunk => {
  42. stdoutBuffer.push(chunk);
  43. })
  44. child.on('exit', (code) => {
  45. if (code) {
  46. reject(code);
  47. return;
  48. }
  49. const result = Buffer.concat(stdoutBuffer).toString('utf8');
  50. resolve(result);
  51. });
  52. });
  53. }
  54. let lastRequest;
  55. async function transifexRequest(url, {
  56. method = 'GET',
  57. responseType = 'json',
  58. data = null,
  59. } = {}) {
  60. const deferred = defer();
  61. const prevRequest = lastRequest;
  62. lastRequest = deferred.promise;
  63. try {
  64. await prevRequest;
  65. let result = await exec(
  66. 'curl',
  67. [
  68. '-sSL',
  69. '--user',
  70. `api:${process.env.TRANSIFEX_TOKEN}`,
  71. '-X',
  72. method,
  73. '-H',
  74. 'Content-Type: application/json',
  75. ...data == null ? [] : ['-d', '@-'],
  76. `https://www.transifex.com${url}`,
  77. ],
  78. {
  79. stdin: data ? JSON.stringify(data) : null,
  80. },
  81. );
  82. if (responseType === 'json') {
  83. result = JSON.parse(result);
  84. }
  85. deferred.resolve(delay(500));
  86. return result;
  87. } catch (err) {
  88. deferred.reject(err);
  89. throw err;
  90. }
  91. }
  92. async function getLanguages() {
  93. const result = await transifexRequest('/api/2/project/violentmonkey-nex/?details');
  94. return result.teams;
  95. }
  96. async function loadRemote(lang) {
  97. // Reference: https://docs.transifex.com/api/translations#downloading-and-uploading-translations
  98. // Use translated messages since we don't have enough reviewers
  99. const result = await transifexRequest(`/api/2/project/violentmonkey-nex/resource/messagesjson/translation/${lang}/?mode=onlytranslated`);
  100. const remote = JSON.parse(result.content);
  101. return remote;
  102. }
  103. const loadData = memoize(async function loadData(lang) {
  104. const remote = await loadRemote(lang);
  105. const filePath = `src/_locales/${lang}/messages.yml`;
  106. const local = yaml.safeLoad(await fs.readFile(filePath, 'utf8'));
  107. return { local, remote, filePath };
  108. });
  109. const loadUpdatedLocales = memoize(async function loadUpdatedLocales() {
  110. const diffUrl = process.env.DIFF_URL;
  111. if (!diffUrl) return;
  112. const result = await exec('curl', ['-sSL', diffUrl]);
  113. // Example:
  114. // diff --git a/src/_locales/ko/messages.yml b/src/_locales/ko/messages.yml
  115. const codes = result.split('\n')
  116. .map(line => {
  117. const matches = line.match(/^diff --git a\/src\/_locales\/([^/]+)\/messages.yml b\/src\/_locales\/([^/]+)\/messages.yml$/);
  118. const [, code1, code2] = matches || [];
  119. return code1 === code2 && code1;
  120. })
  121. .filter(Boolean);
  122. return codes;
  123. });
  124. async function pushTranslations(lang) {
  125. const codes = await loadUpdatedLocales();
  126. // Limit to languages changed in this PR only
  127. if (codes && !codes.includes(lang)) return;
  128. const { local, remote } = await loadData(lang);
  129. const remoteUpdate = {};
  130. Object.entries(local)
  131. .forEach(([key, value]) => {
  132. const remoteMessage = remote[key] && remote[key].message;
  133. if (value.touched !== false && value.message && value.message !== remoteMessage) remoteUpdate[key] = value;
  134. });
  135. if (Object.keys(remoteUpdate).length) {
  136. const strings = await transifexRequest(`/api/2/project/violentmonkey-nex/resource/messagesjson/translation/${lang}/strings/`);
  137. const updates = strings.filter(({ key, reviewed }) => !reviewed && remoteUpdate[key])
  138. .map(({ key, string_hash }) => ({
  139. source_entity_hash: string_hash,
  140. translation: remoteUpdate[key].message,
  141. }));
  142. process.stdout.write(`\n Uploading translations for ${lang}:\n ${JSON.stringify(updates)}\n`);
  143. await transifexRequest(`/api/2/project/violentmonkey-nex/resource/messagesjson/translation/${lang}/strings/`, {
  144. method: 'PUT',
  145. responseType: 'text',
  146. data: updates,
  147. });
  148. process.stdout.write(' finished\n');
  149. } else {
  150. process.stdout.write('up to date\n');
  151. }
  152. }
  153. async function pullTranslations(code) {
  154. const { local, remote, filePath } = await loadData(code);
  155. Object.entries(local)
  156. .forEach(([key, value]) => {
  157. const remoteMessage = remote[key] && remote[key].message;
  158. if (remoteMessage) value.message = remoteMessage;
  159. });
  160. await fs.writeFile(filePath, yaml.safeDump(local), 'utf8');
  161. }
  162. async function main() {
  163. let handle;
  164. if (process.argv.includes('push')) handle = pushTranslations;
  165. else if (process.argv.includes('pull')) handle = pullTranslations;
  166. else process.exit(2);
  167. process.stdout.write('Loading languages...');
  168. const codes = await getLanguages();
  169. process.stdout.write('OK\n');
  170. process.stdout.write(`Got ${codes.length} language codes\n`);
  171. for (const code of codes) {
  172. await fs.mkdir(`src/_locales/${code}`, { recursive: true });
  173. }
  174. spawn.sync('yarn', ['i18n'], { stdio: 'inherit' });
  175. let current = 0;
  176. const showProgress = (lang) => {
  177. process.stdout.write(`\rLoading translations ${lang} (${current}/${codes.length})...`);
  178. };
  179. for (const code of codes) {
  180. current += 1;
  181. showProgress(code);
  182. try {
  183. await handle(code);
  184. } catch (err) {
  185. process.stderr.write(`\nError pulling ${code}\n`)
  186. throw err;
  187. }
  188. }
  189. showProgress('OK');
  190. process.stdout.write('\n');
  191. }
  192. main();