فهرست منبع

chore: update transifex APIs to v3

since v2 is deprecated
Gerald 3 سال پیش
والد
کامیت
fa7d7d898a

+ 1 - 1
.github/workflows/ci.yml

@@ -54,6 +54,6 @@ jobs:
           paths: src
       - name: Upload to Transifex
         if: steps.changed-path-src.outputs.changed == 'true'
-        run: 'curl -i -L --user api:$TRANSIFEX_TOKEN -X PUT -F file=@dist/_locales/en/messages.json https://www.transifex.com/api/2/project/violentmonkey-nex/resource/messagesjson/content/'
+        run: node scripts/transifex.mjs update
         env:
           TRANSIFEX_TOKEN: ${{ secrets.TRANSIFEX_TOKEN }}

+ 2 - 2
.github/workflows/transifex-pull-translations.yml

@@ -13,13 +13,13 @@ jobs:
     steps:
       - name: Checkout code
         uses: actions/checkout@v2
-      - uses: actions/setup-node@v2-beta
+      - uses: actions/setup-node@v2
         with:
           node-version: '16'
       - name: Install deps
         run: yarn
       - name: Update translations
-        run: node scripts/transifex pull
+        run: node scripts/transifex.mjs pull
         env:
           TRANSIFEX_TOKEN: ${{ secrets.TRANSIFEX_TOKEN }}
       - name: Create pull request

+ 2 - 2
.github/workflows/transifex-push-translations.yml

@@ -15,13 +15,13 @@ jobs:
     steps:
       - name: Checkout code
         uses: actions/checkout@v2
-      - uses: actions/setup-node@v2-beta
+      - uses: actions/setup-node@v2
         with:
           node-version: '16'
       - name: Install deps
         run: yarn
       - name: Upload translations
-        run: node scripts/transifex push
+        run: node scripts/transifex.mjs push
         env:
           TRANSIFEX_TOKEN: ${{ secrets.TRANSIFEX_TOKEN }}
           DIFF_URL: ${{ github.event.pull_request.diff_url }}

+ 2 - 1
package.json

@@ -45,6 +45,7 @@
     "husky": "^8.0.1",
     "jest-environment-jsdom": "^29.0.3",
     "js-yaml": "^4.1.0",
+    "node-fetch": "^3.2.10",
     "plugin-error": "^2.0.0",
     "postcss-scss": "^4.0.4",
     "postcss-simple-vars": "^6.0.3",
@@ -82,4 +83,4 @@
     "testEnvironment": "./test/mock/env.js"
   },
   "beta": 1
-}
+}

+ 0 - 205
scripts/transifex.js

@@ -1,205 +0,0 @@
-const fs = require('fs').promises;
-const spawn = require('cross-spawn');
-const yaml = require('js-yaml');
-
-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 exec(cmd, args, options) {
-  return new Promise((resolve, reject) => {
-    const { stdin, ...rest } = options || {};
-    const child = spawn(
-      cmd,
-      args,
-      { ...rest, stdio: ['pipe', 'pipe', 'inherit'] },
-    );
-    if (stdin != null) {
-      child.stdin.write(stdin);
-      child.stdin.end();
-    }
-    const stdoutBuffer = [];
-    child.stdout.on('data', chunk => {
-      stdoutBuffer.push(chunk);
-    })
-    child.on('exit', (code) => {
-      if (code) {
-        reject(code);
-        return;
-      }
-      const result = Buffer.concat(stdoutBuffer).toString('utf8');
-      resolve(result);
-    });
-  });
-}
-
-let lastRequest;
-async function transifexRequest(url, {
-  method = 'GET',
-  responseType = 'json',
-  data = null,
-} = {}) {
-  const deferred = defer();
-  const prevRequest = lastRequest;
-  lastRequest = deferred.promise;
-  try {
-    await prevRequest;
-    let result = await exec(
-      'curl',
-      [
-        '-sSL',
-        '--user',
-        `api:${process.env.TRANSIFEX_TOKEN}`,
-        '-X',
-        method,
-        '-H',
-        'Content-Type: application/json',
-        ...data == null ? [] : ['-d', '@-'],
-        `https://www.transifex.com${url}`,
-      ],
-      {
-        stdin: data ? JSON.stringify(data) : null,
-      },
-    );
-    if (responseType === 'json') {
-      result = JSON.parse(result);
-    }
-    deferred.resolve(delay(500));
-    return result;
-  } catch (err) {
-    deferred.reject(err);
-    throw err;
-  }
-}
-
-async function getLanguages() {
-  const result = await transifexRequest('/api/2/project/violentmonkey-nex/?details');
-  return result.teams;
-}
-
-async function loadRemote(lang) {
-  // Reference: https://docs.transifex.com/api/translations#downloading-and-uploading-translations
-  // Use translated messages since we don't have enough reviewers
-  const result = await transifexRequest(`/api/2/project/violentmonkey-nex/resource/messagesjson/translation/${lang}/?mode=onlytranslated`);
-  const remote = JSON.parse(result.content);
-  return remote;
-}
-
-const loadData = memoize(async function loadData(lang) {
-  const remote = await loadRemote(lang);
-  const filePath = `src/_locales/${lang}/messages.yml`;
-  const local = yaml.load(await fs.readFile(filePath, 'utf8'));
-  return { local, remote, filePath };
-});
-
-const loadUpdatedLocales = memoize(async function loadUpdatedLocales() {
-  const diffUrl = process.env.DIFF_URL;
-  if (!diffUrl) return;
-  const result = await exec('curl', ['-sSL', diffUrl]);
-  // Example:
-  // diff --git a/src/_locales/ko/messages.yml b/src/_locales/ko/messages.yml
-  const codes = 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 codes;
-});
-
-async function pushTranslations(lang) {
-  const codes = await loadUpdatedLocales();
-  // Limit to languages changed in this PR only
-  if (codes && !codes.includes(lang)) return;
-  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 transifexRequest(`/api/2/project/violentmonkey-nex/resource/messagesjson/translation/${lang}/strings/`);
-    const updates = strings.filter(({ key, reviewed }) => !reviewed && remoteUpdate[key])
-      .map(({ key, string_hash }) => ({
-        source_entity_hash: string_hash,
-        translation: remoteUpdate[key].message,
-      }));
-    process.stdout.write(`\n  Uploading translations for ${lang}:\n  ${JSON.stringify(updates)}\n`);
-    await transifexRequest(`/api/2/project/violentmonkey-nex/resource/messagesjson/translation/${lang}/strings/`, {
-      method: 'PUT',
-      responseType: 'text',
-      data: updates,
-    });
-    process.stdout.write('  finished\n');
-  } else {
-    process.stdout.write('up to date\n');
-  }
-}
-
-async function pullTranslations(code) {
-  const { local, remote, filePath } = await loadData(code);
-  Object.entries(local)
-  .forEach(([key, value]) => {
-    const remoteMessage = remote[key] && remote[key].message;
-    if (remoteMessage) value.message = remoteMessage;
-  });
-  await fs.writeFile(filePath, yaml.dump(local), 'utf8');
-}
-
-async function main() {
-  let handle;
-  if (process.argv.includes('push')) handle = pushTranslations;
-  else if (process.argv.includes('pull')) handle = pullTranslations;
-  else process.exit(2);
-  process.stdout.write('Loading languages...');
-  const codes = await getLanguages();
-  process.stdout.write('OK\n');
-  process.stdout.write(`Got ${codes.length} language codes\n`);
-  for (const code of codes) {
-    await fs.mkdir(`src/_locales/${code}`, { recursive: true });
-  }
-  spawn.sync('yarn', ['i18n'], { stdio: 'inherit' });
-  let current = 0;
-  const showProgress = (lang) => {
-    process.stdout.write(`\rLoading translations ${lang} (${current}/${codes.length})...`);
-  };
-  for (const code of codes) {
-    current += 1;
-    showProgress(code);
-    try {
-      await handle(code);
-    } catch (err) {
-      process.stderr.write(`\nError pulling ${code}\n`)
-      throw err;
-    }
-  }
-  showProgress('OK');
-  process.stdout.write('\n');
-}
-
-main();

+ 330 - 0
scripts/transifex.mjs

@@ -0,0 +1,330 @@
+import { readFile, writeFile, mkdir } from 'fs/promises';
+import spawn from 'cross-spawn';
+import yaml from 'js-yaml';
+import fetch from 'node-fetch';
+
+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 pulling ${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;
+});

+ 1 - 1
yarn.lock

@@ -7452,7 +7452,7 @@ node-fetch@^2.3.0, node-fetch@^2.6.7:
   dependencies:
     whatwg-url "^5.0.0"
 
-node-fetch@^3.2.6:
+node-fetch@^3.2.10, node-fetch@^3.2.6:
   version "3.2.10"
   resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.2.10.tgz#e8347f94b54ae18b57c9c049ef641cef398a85c8"
   integrity sha512-MhuzNwdURnZ1Cp4XTazr69K0BTizsBroX7Zx3UgDSVcZYKF/6p0CBe4EUb/hLqmzVhl0UpYfgRljQ4yxE+iCxA==