transifex.mjs 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. import { readFile, writeFile, mkdir } from 'fs/promises';
  2. import spawn from 'cross-spawn';
  3. import yaml from 'js-yaml';
  4. import fetch from 'node-fetch';
  5. const PROJECT_ID = 'o:violentmonkey:p:violentmonkey-nex';
  6. const RESOURCE_ID = `${PROJECT_ID}:r:messagesjson`;
  7. const RESOURCE_FILE = 'src/_locales/en/messages.yml';
  8. const request = limitConcurrency(doRequest, 5);
  9. function delay(time) {
  10. return new Promise(resolve => setTimeout(resolve, time));
  11. }
  12. function defer() {
  13. const deferred = {};
  14. deferred.promise = new Promise((resolve, reject) => {
  15. deferred.resolve = resolve;
  16. deferred.reject = reject;
  17. });
  18. return deferred;
  19. }
  20. function memoize(fn) {
  21. const cache = {};
  22. function wrapped(...args) {
  23. const key = args.toString();
  24. let result = cache[key];
  25. if (!result) {
  26. result = { data: fn(...args) };
  27. cache[key] = result;
  28. }
  29. return result.data;
  30. }
  31. return wrapped;
  32. }
  33. function limitConcurrency(fn, concurrency) {
  34. const tokens = [];
  35. const processing = new Set();
  36. async function getToken() {
  37. const token = defer();
  38. tokens.push(token);
  39. check();
  40. await token.promise;
  41. return token;
  42. }
  43. function releaseToken(token) {
  44. processing.delete(token);
  45. check();
  46. }
  47. function check() {
  48. while (tokens.length && processing.size < concurrency) {
  49. const token = tokens.shift();
  50. processing.add(token);
  51. token.resolve();
  52. }
  53. }
  54. async function limited(...args) {
  55. const token = await getToken();
  56. try {
  57. return await fn(...args);
  58. } finally {
  59. releaseToken(token);
  60. }
  61. }
  62. return limited;
  63. }
  64. async function doRequest(path, options) {
  65. options = {
  66. method: 'GET',
  67. ...options,
  68. };
  69. const init = {
  70. method: options.method,
  71. headers: {
  72. accept: 'application/vnd.api+json',
  73. ...options.headers,
  74. authorization: `Bearer ${process.env.TRANSIFEX_TOKEN}`,
  75. },
  76. };
  77. const qs = options.query ? `?${new URLSearchParams(options.query)}` : '';
  78. if (options.body) {
  79. init.headers['content-type'] ||= 'application/vnd.api+json';
  80. init.body = JSON.stringify(options.body);
  81. }
  82. if (!path.includes('://')) path = `https://rest.api.transifex.com${path}`;
  83. const resp = await fetch(path + qs, init);
  84. const isJson = /[+/]json$/.test(resp.headers.get('content-type').split(';')[0]);
  85. const result = await resp[isJson ? 'json' : 'text']();
  86. if (!resp.ok) throw { resp, result };
  87. return { resp, result };
  88. }
  89. async function uploadResource() {
  90. const source = yaml.load(await readFile(RESOURCE_FILE, 'utf8'));
  91. const content = Object.entries(source).reduce((prev, [key, value]) => {
  92. if (value.touched !== false) {
  93. prev[key] = {
  94. description: value.description,
  95. message: value.message,
  96. };
  97. }
  98. return prev;
  99. }, {});
  100. let { result } = await request('/resource_strings_async_uploads', {
  101. method: 'POST',
  102. body: {
  103. data: {
  104. type: 'resource_strings_async_uploads',
  105. attributes: {
  106. content: JSON.stringify(content),
  107. content_encoding: 'text',
  108. },
  109. relationships: {
  110. resource: {
  111. data: {
  112. id: RESOURCE_ID,
  113. type: 'resources',
  114. },
  115. },
  116. },
  117. },
  118. },
  119. });
  120. while (['pending', 'processing'].includes(result.data.attributes.status)) {
  121. await delay(500);
  122. ({ result } = await request(`/resource_strings_async_uploads/${result.data.id}`));
  123. }
  124. if (result.data.attributes.status !== 'succeeded') throw { result };
  125. return result.data.attributes.details;
  126. }
  127. async function getLanguages() {
  128. const { result } = await request(`/projects/${PROJECT_ID}/languages`);
  129. return result.data.map(({ attributes: { code } }) => code);
  130. }
  131. async function loadRemote(lang) {
  132. let { resp, result } = await request('/resource_translations_async_downloads', {
  133. method: 'POST',
  134. body: {
  135. data: {
  136. type: 'resource_translations_async_downloads',
  137. attributes: {
  138. mode: 'onlytranslated',
  139. },
  140. relationships: {
  141. language: {
  142. data: {
  143. id: `l:${lang}`,
  144. type: 'languages',
  145. },
  146. },
  147. resource: {
  148. data: {
  149. id: RESOURCE_ID,
  150. type: 'resources',
  151. },
  152. },
  153. },
  154. },
  155. },
  156. });
  157. while (!resp.redirected && ['pending', 'processing', 'succeeded'].includes(result.data.attributes.status)) {
  158. await delay(500);
  159. ({ resp, result } = await request(`/resource_translations_async_downloads/${result.data.id}`));
  160. }
  161. if (!resp.redirected) throw { resp, result };
  162. return result;
  163. }
  164. async function getTranslations(lang) {
  165. let { result } = await request('/resource_translations', {
  166. query: {
  167. 'filter[resource]': RESOURCE_ID,
  168. 'filter[language]': `l:${lang}`,
  169. include: 'resource_string',
  170. },
  171. });
  172. let { data, included } = result;
  173. while (result.links.next) {
  174. ({ result } = await request(result.links.next));
  175. data = data.concat(result.data);
  176. included = included.concat(result.included);
  177. }
  178. const includedMap = included.reduce((prev, item) => {
  179. prev[item.id] = item;
  180. return prev;
  181. }, {});
  182. data.forEach(item => {
  183. Object.assign(item.relationships.resource_string.data, includedMap[item.relationships.resource_string.data.id]);
  184. });
  185. return data;
  186. }
  187. async function updateTranslations(updates) {
  188. const { result } = await request('/resource_translations', {
  189. method: 'PATCH',
  190. headers: {
  191. 'content-type': 'application/vnd.api+json;profile="bulk"',
  192. },
  193. body: {
  194. data: updates,
  195. },
  196. });
  197. return result.data;
  198. }
  199. const loadData = memoize(async function loadData(lang) {
  200. const remote = await loadRemote(lang);
  201. const filePath = `src/_locales/${lang}/messages.yml`;
  202. const local = yaml.load(await readFile(filePath, 'utf8'));
  203. return { local, remote, filePath };
  204. });
  205. async function loadUpdatedLocales() {
  206. const diffUrl = process.env.DIFF_URL;
  207. if (!diffUrl) return;
  208. const res = await fetch(diffUrl);
  209. const result = await res.text();
  210. // Example:
  211. // diff --git a/src/_locales/ko/messages.yml b/src/_locales/ko/messages.yml
  212. const langs = result.split('\n')
  213. .map(line => {
  214. const matches = line.match(/^diff --git a\/src\/_locales\/([^/]+)\/messages.yml b\/src\/_locales\/([^/]+)\/messages.yml$/);
  215. const [, code1, code2] = matches || [];
  216. return code1 === code2 && code1;
  217. })
  218. .filter(Boolean);
  219. return langs;
  220. }
  221. async function pushTranslations(lang) {
  222. const { local, remote } = await loadData(lang);
  223. const remoteUpdate = {};
  224. Object.entries(local)
  225. .forEach(([key, value]) => {
  226. const remoteMessage = remote[key] && remote[key].message;
  227. if (value.touched !== false && value.message && value.message !== remoteMessage) remoteUpdate[key] = value;
  228. });
  229. if (Object.keys(remoteUpdate).length) {
  230. const strings = await getTranslations(lang);
  231. const updates = strings.filter(item => !item.attributes.reviewed && remoteUpdate[item.relationships.resource_string.data.attributes.key])
  232. .map(item => ({
  233. id: item.id,
  234. type: item.type,
  235. attributes: {
  236. strings: {
  237. other: remoteUpdate[item.relationships.resource_string.data.attributes.key].message,
  238. },
  239. },
  240. }));
  241. process.stdout.write(`\n Uploading translations for ${lang}:\n ${JSON.stringify(updates)}\n`);
  242. await updateTranslations(updates);
  243. process.stdout.write(' finished\n');
  244. } else {
  245. process.stdout.write('up to date\n');
  246. }
  247. }
  248. async function pullTranslations(lang) {
  249. const { local, remote, filePath } = await loadData(lang);
  250. Object.entries(local)
  251. .forEach(([key, value]) => {
  252. const remoteMessage = remote[key] && remote[key].message;
  253. if (remoteMessage) value.message = remoteMessage;
  254. });
  255. await writeFile(filePath, yaml.dump(local), 'utf8');
  256. }
  257. async function batchHandle(handle, allowedLangs) {
  258. process.stdout.write('Loading languages...');
  259. let langs = await getLanguages();
  260. process.stdout.write('OK\n');
  261. process.stdout.write(`Got ${langs.length} language codes\n`);
  262. for (const lang of langs) {
  263. await mkdir(`src/_locales/${lang}`, { recursive: true });
  264. }
  265. spawn.sync('yarn', ['i18n'], { stdio: 'inherit' });
  266. if (allowedLangs) langs = langs.filter(lang => allowedLangs.includes(lang));
  267. let finished = 0;
  268. const showProgress = () => {
  269. process.stdout.write(`\rHandling translations (${finished}/${langs.length})...`);
  270. };
  271. showProgress();
  272. await Promise.all(langs.map(async lang => {
  273. try {
  274. await handle(lang);
  275. finished += 1;
  276. showProgress();
  277. } catch (err) {
  278. process.stderr.write(`\nError handling ${lang}\n`);
  279. throw err;
  280. }
  281. }));
  282. process.stdout.write('\n');
  283. }
  284. async function updateResource() {
  285. const result = await uploadResource();
  286. console.log(Object.entries(result).map(([key, value]) => `${key}: ${value}`).join(', '));
  287. }
  288. async function main() {
  289. const [,, command] = process.argv;
  290. switch (command) {
  291. case 'update': {
  292. return updateResource();
  293. }
  294. case 'push': {
  295. // Limit to languages changed in this PR only
  296. const allowedLangs = await loadUpdatedLocales();
  297. return batchHandle(pushTranslations, allowedLangs);
  298. }
  299. case 'pull': {
  300. return batchHandle(pullTranslations);
  301. }
  302. default: {
  303. throw new Error(`Unknown command: ${command}`);
  304. }
  305. }
  306. }
  307. main().catch(err => {
  308. console.error(err);
  309. if (err?.result) console.error('Response:', JSON.stringify(err.result, null, 2));
  310. process.exitCode = 1;
  311. });