123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329 |
- import { readFile, writeFile, mkdir } from 'fs/promises';
- import spawn from 'cross-spawn';
- import yaml from 'js-yaml';
- const PROJECT_ID = 'o:violentmonkey:p:violentmonkey-nex';
- const RESOURCE_ID = `${PROJECT_ID}:r:messagesjson`;
- const RESOURCE_FILE = 'src/_locales/en/messages.yml';
- const request = limitConcurrency(doRequest, 5);
- function delay(time) {
- return new Promise(resolve => setTimeout(resolve, time));
- }
- function defer() {
- const deferred = {};
- deferred.promise = new Promise((resolve, reject) => {
- deferred.resolve = resolve;
- deferred.reject = reject;
- });
- return deferred;
- }
- function memoize(fn) {
- const cache = {};
- function wrapped(...args) {
- const key = args.toString();
- let result = cache[key];
- if (!result) {
- result = { data: fn(...args) };
- cache[key] = result;
- }
- return result.data;
- }
- return wrapped;
- }
- function limitConcurrency(fn, concurrency) {
- const tokens = [];
- const processing = new Set();
- async function getToken() {
- const token = defer();
- tokens.push(token);
- check();
- await token.promise;
- return token;
- }
- function releaseToken(token) {
- processing.delete(token);
- check();
- }
- function check() {
- while (tokens.length && processing.size < concurrency) {
- const token = tokens.shift();
- processing.add(token);
- token.resolve();
- }
- }
- async function limited(...args) {
- const token = await getToken();
- try {
- return await fn(...args);
- } finally {
- releaseToken(token);
- }
- }
- return limited;
- }
- async function doRequest(path, options) {
- options = {
- method: 'GET',
- ...options,
- };
- const init = {
- method: options.method,
- headers: {
- accept: 'application/vnd.api+json',
- ...options.headers,
- authorization: `Bearer ${process.env.TRANSIFEX_TOKEN}`,
- },
- };
- const qs = options.query ? `?${new URLSearchParams(options.query)}` : '';
- if (options.body) {
- init.headers['content-type'] ||= 'application/vnd.api+json';
- init.body = JSON.stringify(options.body);
- }
- if (!path.includes('://')) path = `https://rest.api.transifex.com${path}`;
- const resp = await fetch(path + qs, init);
- const isJson = /[+/]json$/.test(resp.headers.get('content-type').split(';')[0]);
- const result = await resp[isJson ? 'json' : 'text']();
- if (!resp.ok) throw { resp, result };
- return { resp, result };
- }
- async function uploadResource() {
- const source = yaml.load(await readFile(RESOURCE_FILE, 'utf8'));
- const content = Object.entries(source).reduce((prev, [key, value]) => {
- if (value.touched !== false) {
- prev[key] = {
- description: value.description,
- message: value.message,
- };
- }
- return prev;
- }, {});
- let { result } = await request('/resource_strings_async_uploads', {
- method: 'POST',
- body: {
- data: {
- type: 'resource_strings_async_uploads',
- attributes: {
- content: JSON.stringify(content),
- content_encoding: 'text',
- },
- relationships: {
- resource: {
- data: {
- id: RESOURCE_ID,
- type: 'resources',
- },
- },
- },
- },
- },
- });
- while (['pending', 'processing'].includes(result.data.attributes.status)) {
- await delay(500);
- ({ result } = await request(`/resource_strings_async_uploads/${result.data.id}`));
- }
- if (result.data.attributes.status !== 'succeeded') throw { result };
- return result.data.attributes.details;
- }
- async function getLanguages() {
- const { result } = await request(`/projects/${PROJECT_ID}/languages`);
- return result.data.map(({ attributes: { code } }) => code);
- }
- async function loadRemote(lang) {
- let { resp, result } = await request('/resource_translations_async_downloads', {
- method: 'POST',
- body: {
- data: {
- type: 'resource_translations_async_downloads',
- attributes: {
- mode: 'onlytranslated',
- },
- relationships: {
- language: {
- data: {
- id: `l:${lang}`,
- type: 'languages',
- },
- },
- resource: {
- data: {
- id: RESOURCE_ID,
- type: 'resources',
- },
- },
- },
- },
- },
- });
- while (!resp.redirected && ['pending', 'processing', 'succeeded'].includes(result.data.attributes.status)) {
- await delay(500);
- ({ resp, result } = await request(`/resource_translations_async_downloads/${result.data.id}`));
- }
- if (!resp.redirected) throw { resp, result };
- return result;
- }
- async function getTranslations(lang) {
- let { result } = await request('/resource_translations', {
- query: {
- 'filter[resource]': RESOURCE_ID,
- 'filter[language]': `l:${lang}`,
- include: 'resource_string',
- },
- });
- let { data, included } = result;
- while (result.links.next) {
- ({ result } = await request(result.links.next));
- data = data.concat(result.data);
- included = included.concat(result.included);
- }
- const includedMap = included.reduce((prev, item) => {
- prev[item.id] = item;
- return prev;
- }, {});
- data.forEach(item => {
- Object.assign(item.relationships.resource_string.data, includedMap[item.relationships.resource_string.data.id]);
- });
- return data;
- }
- async function updateTranslations(updates) {
- const { result } = await request('/resource_translations', {
- method: 'PATCH',
- headers: {
- 'content-type': 'application/vnd.api+json;profile="bulk"',
- },
- body: {
- data: updates,
- },
- });
- return result.data;
- }
- const loadData = memoize(async function loadData(lang) {
- const remote = await loadRemote(lang);
- const filePath = `src/_locales/${lang}/messages.yml`;
- const local = yaml.load(await readFile(filePath, 'utf8'));
- return { local, remote, filePath };
- });
- async function loadUpdatedLocales() {
- const diffUrl = process.env.DIFF_URL;
- if (!diffUrl) return;
- const res = await fetch(diffUrl);
- const result = await res.text();
- // Example:
- // diff --git a/src/_locales/ko/messages.yml b/src/_locales/ko/messages.yml
- const langs = result.split('\n')
- .map(line => {
- const matches = line.match(/^diff --git a\/src\/_locales\/([^/]+)\/messages.yml b\/src\/_locales\/([^/]+)\/messages.yml$/);
- const [, code1, code2] = matches || [];
- return code1 === code2 && code1;
- })
- .filter(Boolean);
- return langs;
- }
- async function pushTranslations(lang) {
- const { local, remote } = await loadData(lang);
- const remoteUpdate = {};
- Object.entries(local)
- .forEach(([key, value]) => {
- const remoteMessage = remote[key] && remote[key].message;
- if (value.touched !== false && value.message && value.message !== remoteMessage) remoteUpdate[key] = value;
- });
- if (Object.keys(remoteUpdate).length) {
- const strings = await getTranslations(lang);
- const updates = strings.filter(item => !item.attributes.reviewed && remoteUpdate[item.relationships.resource_string.data.attributes.key])
- .map(item => ({
- id: item.id,
- type: item.type,
- attributes: {
- strings: {
- other: remoteUpdate[item.relationships.resource_string.data.attributes.key].message,
- },
- },
- }));
- process.stdout.write(`\n Uploading translations for ${lang}:\n ${JSON.stringify(updates)}\n`);
- await updateTranslations(updates);
- process.stdout.write(' finished\n');
- } else {
- process.stdout.write('up to date\n');
- }
- }
- async function pullTranslations(lang) {
- const { local, remote, filePath } = await loadData(lang);
- Object.entries(local)
- .forEach(([key, value]) => {
- const remoteMessage = remote[key] && remote[key].message;
- if (remoteMessage) value.message = remoteMessage;
- });
- await writeFile(filePath, yaml.dump(local), 'utf8');
- }
- async function batchHandle(handle, allowedLangs) {
- process.stdout.write('Loading languages...');
- let langs = await getLanguages();
- process.stdout.write('OK\n');
- process.stdout.write(`Got ${langs.length} language codes\n`);
- for (const lang of langs) {
- await mkdir(`src/_locales/${lang}`, { recursive: true });
- }
- spawn.sync('yarn', ['i18n'], { stdio: 'inherit' });
- if (allowedLangs) langs = langs.filter(lang => allowedLangs.includes(lang));
- let finished = 0;
- const showProgress = () => {
- process.stdout.write(`\rHandling translations (${finished}/${langs.length})...`);
- };
- showProgress();
- await Promise.all(langs.map(async lang => {
- try {
- await handle(lang);
- finished += 1;
- showProgress();
- } catch (err) {
- process.stderr.write(`\nError handling ${lang}\n`);
- throw err;
- }
- }));
- process.stdout.write('\n');
- }
- async function updateResource() {
- const result = await uploadResource();
- console.log(Object.entries(result).map(([key, value]) => `${key}: ${value}`).join(', '));
- }
- async function main() {
- const [,, command] = process.argv;
- switch (command) {
- case 'update': {
- return updateResource();
- }
- case 'push': {
- // Limit to languages changed in this PR only
- const allowedLangs = await loadUpdatedLocales();
- return batchHandle(pushTranslations, allowedLangs);
- }
- case 'pull': {
- return batchHandle(pullTranslations);
- }
- default: {
- throw new Error(`Unknown command: ${command}`);
- }
- }
- }
- main().catch(err => {
- console.error(err);
- if (err?.result) console.error('Response:', JSON.stringify(err.result, null, 2));
- process.exitCode = 1;
- });
|