1
0

transifex.mjs 9.3 KB

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