From 00955830282d482d9144c5e9dd2b66b8f557278e Mon Sep 17 00:00:00 2001 From: Randell Dawson <5313213+RandellDawson@users.noreply.github.com> Date: Tue, 12 Jan 2021 11:20:54 -0700 Subject: [PATCH] feat: Crowdin integration scripts/actions (#40657) --- .github/workflows/crowdin-i18n-download.yml | 54 ++++++++ .github/workflows/crowdin-i18n-upload.yml | 64 +++++++++ .github/workflows/crowdin-i18n.yml | 47 ------- curriculum/crowdin.yml | 29 +--- .../hide-non-translated-strings/action.yml | 5 + .../hide-non-translated-strings/index.js | 35 +++++ .../actions/remove-deleted-files/action.yml | 5 + .../actions/remove-deleted-files/index.js | 46 +++++++ tools/crowdin/package-lock.json | 106 +++++++++++++++ tools/crowdin/package.json | 18 +++ tools/crowdin/utils/auth-header.js | 7 + tools/crowdin/utils/delay.js | 5 + tools/crowdin/utils/dirs.js | 86 ++++++++++++ tools/crowdin/utils/files.js | 124 ++++++++++++++++++ tools/crowdin/utils/make-request.js | 34 +++++ tools/crowdin/utils/strings.js | 74 +++++++++++ 16 files changed, 670 insertions(+), 69 deletions(-) create mode 100644 .github/workflows/crowdin-i18n-download.yml create mode 100644 .github/workflows/crowdin-i18n-upload.yml delete mode 100644 .github/workflows/crowdin-i18n.yml create mode 100644 tools/crowdin/actions/hide-non-translated-strings/action.yml create mode 100644 tools/crowdin/actions/hide-non-translated-strings/index.js create mode 100644 tools/crowdin/actions/remove-deleted-files/action.yml create mode 100644 tools/crowdin/actions/remove-deleted-files/index.js create mode 100644 tools/crowdin/package-lock.json create mode 100644 tools/crowdin/package.json create mode 100644 tools/crowdin/utils/auth-header.js create mode 100644 tools/crowdin/utils/delay.js create mode 100644 tools/crowdin/utils/dirs.js create mode 100644 tools/crowdin/utils/files.js create mode 100644 tools/crowdin/utils/make-request.js create mode 100644 tools/crowdin/utils/strings.js diff --git a/.github/workflows/crowdin-i18n-download.yml b/.github/workflows/crowdin-i18n-download.yml new file mode 100644 index 0000000000..35d7f1c451 --- /dev/null +++ b/.github/workflows/crowdin-i18n-download.yml @@ -0,0 +1,54 @@ +name: Crowdin i18n Download Translations +on: + workflow_dispatch: + push: + branches: + - test-crowdin-download + +jobs: + + i18n-download-curriculum-translations: + name: Learn + runs-on: ubuntu-18.04 + + steps: + - name: Checkout Source Files + uses: actions/checkout@v2 + + ##### Chinese Download ##### + - name: Crowdin Download for Chinese Translations + uses: crowdin/github-action@master + # options: https://github.com/crowdin/github-action/blob/master/action.yml + with: + # uploads + upload_sources: false + upload_translations: false + auto_approve_imported: false + import_eq_suggestions: false + + # downloads + download_language: zh-CN + download_translations: true + skip_untranslated_files: true + export_only_approved: true + + commit_message: 'chore(i8n,learn): processed chinese translations' + + # pull-request + localization_branch_name: i18n-sync-learn-processed-chinese-translations + create_pull_request: false + pull_request_title: 'chore(i18n,learn): Processed chinese translations from crowdin' + pull_request_body: '' + pull_request_labels: 'scope: i18n, scope: learn, crowdin-sync, language: Chinese' + + # global options + config: './curriculum/crowdin.yml' + base_url: ${{ secrets.CROWDIN_BASE_URL_FCC }} + + # Uncomment below to debug + # dryrun_action: true + + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID_CURRICULUM }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_CAMPERBOT_SERVICE_TOKEN }} diff --git a/.github/workflows/crowdin-i18n-upload.yml b/.github/workflows/crowdin-i18n-upload.yml new file mode 100644 index 0000000000..6465c9cb9c --- /dev/null +++ b/.github/workflows/crowdin-i18n-upload.yml @@ -0,0 +1,64 @@ +name: Crowdin i18n Upload Action +on: + workflow_dispatch: + push: + branches: + - test-crowdin-upload + +jobs: + + i18n-upload-curriculum-files: + name: Learn + runs-on: ubuntu-18.04 + + steps: + - name: Checkout Source Files + uses: actions/checkout@v2 + + - name: Install Dependencies + working-directory: ./tools + run: | + cd ./crowdin + npm ci + + - name: Crowdin Upload + uses: crowdin/github-action@master + # options: https://github.com/crowdin/github-action/blob/master/action.yml + with: + # uploads + upload_sources: true + upload_translations: true + auto_approve_imported: false + import_eq_suggestions: false + + # downloads + download_translations: false + + # pull-request + create_pull_request: false + + # global options + config: './curriculum/crowdin.yml' + base_url: ${{ secrets.CROWDIN_BASE_URL_FCC }} + + # Uncomment below to debug + # dryrun_action: true + + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID_CURRICULUM }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_CAMPERBOT_SERVICE_TOKEN }} + + - name: Remove Deleted English Curriculum Files From Crowdin + uses: ./tools/crowdin/actions/remove-deleted-files + env: + CROWDIN_API_URL: 'https://freecodecamp.crowdin.com/api/v2/' + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_CAMPERBOT_SERVICE_TOKEN }} + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID_CURRICULUM }} + + - name: Hide Non-Translated Strings + uses: ./tools/crowdin/actions/hide-non-translated-strings + env: + CROWDIN_API_URL: 'https://freecodecamp.crowdin.com/api/v2/' + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_CAMPERBOT_SERVICE_TOKEN }} + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID_CURRICULUM }} \ No newline at end of file diff --git a/.github/workflows/crowdin-i18n.yml b/.github/workflows/crowdin-i18n.yml deleted file mode 100644 index a9b317592f..0000000000 --- a/.github/workflows/crowdin-i18n.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Crowdin Sync -on: - workflow_dispatch: - # schedule: - # (TODO?) run this Action every 14 days - # - cron: '0 * */14 * *' - -jobs: - i18n-sync-docs: - name: Docs - runs-on: ubuntu-18.04 - - steps: - - name: Checkout Source Files - uses: actions/checkout@v2 - - - name: Crowdin Synchronize - uses: crowdin/github-action@master - # options: https://github.com/crowdin/github-action/blob/master/action.yml - with: - # uploads - upload_translations: false - auto_approve_imported: false - import_eq_suggestions: false - - # downloads - download_translations: true - commit_message: 'chore(i8n,docs): processed translations from crowdin' - - # pull-request - localization_branch_name: i18n-sync-docs - create_pull_request: true - pull_request_title: 'chore(i18n,docs): processed translations from crowdin' - pull_request_body: '' - pull_request_labels: 'scope: i18n, scope: docs, crowdin-sync' - - # global options - config: './docs/crowdin.yml' - base_url: ${{ secrets.CROWDIN_BASE_URL_FCC }} - - # Uncomment below to debug - # dryrun_action: true - - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID_DOCS }} - CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_CAMPERBOT_SERVICE_TOKEN }} diff --git a/curriculum/crowdin.yml b/curriculum/crowdin.yml index f864931b1c..5bedcda851 100644 --- a/curriculum/crowdin.yml +++ b/curriculum/crowdin.yml @@ -1,4 +1,4 @@ -"project_id_env": "CROWDIN_PROJECT_ID_CURRICULUM" +"project_id_env": "CROWDIN_PROJECT_ID" "api_token_env": "CROWDIN_PERSONAL_TOKEN" "base_path_env": "CROWDIN_BASE_PATH" "base_url_env": "CROWDIN_BASE_URL" @@ -7,27 +7,12 @@ files: [ { - "source" : "/curriculum/challenges/english/**/*.md", - "translation" : "/curriculum/challenges/%language%/**/%original_file_name%", - - # "languages_mapping" : { - # "language" : { - - # } - # }, - + "source" : "/curriculum/challenges/english/01-responsive-web-design/**/*.md", + "translation" : "/curriculum/challenges/%language%/01-responsive-web-design/**/%original_file_name%", + "ignore": [ + "/**/part-[0-9][0-9][0-9].md", + "/curriculum/challenges/english/[0-9][0-9]-certificates/**/*.*" + ], "update_option": "update_as_unapproved" - }, - { - "source": "/curriculum/dictionaries/english/**/*.js", - "translation": "/curriculum/dictionaries/%language%/**/%original_file_name%", - - # "languages_mapping" : { - # "language" : { - - # } - # }, - - "update_option": "update_without_changes" } ] diff --git a/tools/crowdin/actions/hide-non-translated-strings/action.yml b/tools/crowdin/actions/hide-non-translated-strings/action.yml new file mode 100644 index 0000000000..443c8a12eb --- /dev/null +++ b/tools/crowdin/actions/hide-non-translated-strings/action.yml @@ -0,0 +1,5 @@ +name: 'Hide Non-Translated Strings' +description: "Updates each file's non-translatable string to Hidden" +runs: + using: 'node12' + main: './index.js' \ No newline at end of file diff --git a/tools/crowdin/actions/hide-non-translated-strings/index.js b/tools/crowdin/actions/hide-non-translated-strings/index.js new file mode 100644 index 0000000000..4deafc614c --- /dev/null +++ b/tools/crowdin/actions/hide-non-translated-strings/index.js @@ -0,0 +1,35 @@ +require('dotenv').config({ path: `${__dirname}/../../.env` }); +// const core = require('@actions/core'); +const fs = require('fs'); +const path = require('path'); +const matter = require('gray-matter'); +const { getFiles } = require('../../utils/files'); +const { updateFileStrings } = require('../../utils/strings'); + +const hideNonTranslatedStrings = async projectId => { + console.log('start hiding non-translated strings...'); + const crowdinFiles = await getFiles(projectId); + if (crowdinFiles && crowdinFiles.length) { + for (let { fileId, path: crowdinFilePath } of crowdinFiles) { + const challengeFilePath = path.join( + __dirname, + '/../../../../', + crowdinFilePath + ); + try { + const challengeContent = fs.readFileSync(challengeFilePath); + const { + data: { title: challengeTitle } + } = matter(challengeContent); + await updateFileStrings({ projectId, fileId, challengeTitle }); + } catch (err) { + console.log(err.name); + console.log(err.message); + } + } + } + console.log('hiding non-translated strings complete'); +}; + +const projectId = process.env.CROWDIN_PROJECT_ID; +hideNonTranslatedStrings(projectId); diff --git a/tools/crowdin/actions/remove-deleted-files/action.yml b/tools/crowdin/actions/remove-deleted-files/action.yml new file mode 100644 index 0000000000..291547662f --- /dev/null +++ b/tools/crowdin/actions/remove-deleted-files/action.yml @@ -0,0 +1,5 @@ +name: 'Remove Deleted English Files From Crowdin' +description: 'Deletes files from Crowdin that are no longer in the English curriculum folder' +runs: + using: 'node12' + main: './index.js' \ No newline at end of file diff --git a/tools/crowdin/actions/remove-deleted-files/index.js b/tools/crowdin/actions/remove-deleted-files/index.js new file mode 100644 index 0000000000..3cd07adbc0 --- /dev/null +++ b/tools/crowdin/actions/remove-deleted-files/index.js @@ -0,0 +1,46 @@ +require('dotenv').config({ path: `${__dirname}/../../.env` }); +// const core = require('@actions/core'); +const util = require('util'); +const exec = util.promisify(require('child_process').exec); + +const { getFiles, deleteFile } = require('../../utils/files'); + +const getOutputFromCommand = async command => { + try { + const { stdout } = await exec(command); + return stdout; + } catch (err) { + console.log('Error'); + console.log('command'); + console.log(command + '\n'); + console.log(err.message); + return null; + } +}; + +const removeDeletedFiles = async projectId => { + console.log('start deleting source files no longer in English curriculum...'); + const crowdinFiles = await getFiles(projectId); + + if (crowdinFiles && crowdinFiles.length) { + const command = 'find curriculum/challenges/english -name \\*.md'; + const listOfEnglishFiles = await getOutputFromCommand(command); + const curriculumFilesArr = listOfEnglishFiles.split('\n'); + if (curriculumFilesArr.length) { + const curriculumLookup = curriculumFilesArr.reduce((obj, filename) => { + return { ...obj, [filename]: 1 }; + }, {}); + for (let { fileId, path: crowdinFilePath } of crowdinFiles) { + if (!curriculumLookup.hasOwnProperty(crowdinFilePath)) { + await deleteFile(projectId, fileId, crowdinFilePath); + } + } + } + } else { + console.log(`WARNING! No Crowdin files found for projectId ${projectId}`); + } + console.log('deleting source non-existent source files complete'); +}; + +const projectId = process.env.CROWDIN_PROJECT_ID; +removeDeletedFiles(projectId); diff --git a/tools/crowdin/package-lock.json b/tools/crowdin/package-lock.json new file mode 100644 index 0000000000..bc3de3d70a --- /dev/null +++ b/tools/crowdin/package-lock.json @@ -0,0 +1,106 @@ +{ + "name": "crowdin", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@actions/core": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.2.6.tgz", + "integrity": "sha512-ZQYitnqiyBc3D+k7LsgSBmMDVkOVidaagDG7j3fOym77jNunWRuYx7VSHa9GNfFZh+zh61xsCjRj4JxMZlDqTA==" + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "dotenv": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", + "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==" + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "requires": { + "is-extendable": "^0.1.0" + } + }, + "gray-matter": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.2.tgz", + "integrity": "sha512-7hB/+LxrOjq/dd8APlK0r24uL/67w7SkYnfwhNFwg/VDIGWGmduTDYf3WNstLW2fbbmRwrDGCVSJ2isuf2+4Hw==", + "requires": { + "js-yaml": "^3.11.0", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + } + }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=" + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==" + }, + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" + }, + "picomatch": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", + "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==" + }, + "readdirp": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", + "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==", + "requires": { + "picomatch": "^2.2.1" + } + }, + "section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "requires": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" + }, + "strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha1-5SEekiQ2n7uB1jOi8ABE3IztrZI=" + } + } +} diff --git a/tools/crowdin/package.json b/tools/crowdin/package.json new file mode 100644 index 0000000000..13b3a4c001 --- /dev/null +++ b/tools/crowdin/package.json @@ -0,0 +1,18 @@ +{ + "name": "crowdin", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "freeCodeCamp", + "license": "ISC", + "dependencies": { + "@actions/core": "^1.2.6", + "dotenv": "^8.2.0", + "gray-matter": "^4.0.2", + "node-fetch": "^2.6.1", + "readdirp": "^3.5.0" + } +} diff --git a/tools/crowdin/utils/auth-header.js b/tools/crowdin/utils/auth-header.js new file mode 100644 index 0000000000..2e216f8f0d --- /dev/null +++ b/tools/crowdin/utils/auth-header.js @@ -0,0 +1,7 @@ +require('dotenv').config(); + +const authHeader = { + Authorization: `Bearer ${process.env.CROWDIN_PERSONAL_TOKEN}` +}; + +module.exports = authHeader; diff --git a/tools/crowdin/utils/delay.js b/tools/crowdin/utils/delay.js new file mode 100644 index 0000000000..a311e0cdfc --- /dev/null +++ b/tools/crowdin/utils/delay.js @@ -0,0 +1,5 @@ +const delay = (time = 2000) => { + return new Promise(resolve => setTimeout(() => resolve(true), time)); +}; + +module.exports = delay; diff --git a/tools/crowdin/utils/dirs.js b/tools/crowdin/utils/dirs.js new file mode 100644 index 0000000000..e853b7a053 --- /dev/null +++ b/tools/crowdin/utils/dirs.js @@ -0,0 +1,86 @@ +const makeRequest = require('./make-request'); +const delay = require('./delay'); +const authHeader = require('./auth-header'); + +const getDirs = async projectId => { + let headers = { ...authHeader }; + let done = false; + let offset = 0; + let files = []; + while (!done) { + const endPoint = `projects/${projectId}/directories?limit=500&offset=${offset}`; + await delay(1000); + const response = await makeRequest({ + method: 'get', + endPoint, + headers + }); + if (response.data) { + if (response.data.length) { + files = [...files, ...response.data]; + offset += 500; + } else { + done = true; + return files; + } + } else { + const { error } = response; + console.log(error.errorcode); + console.log(error.messsage); + } + } + return null; +}; + +const addDir = async (projectId, dirName, parentDirId) => { + let headers = { ...authHeader }; + const endPoint = `projects/${projectId}/directories`; + let body = { + name: dirName + }; + if (parentDirId) { + body = { ...body, directoryId: parentDirId }; + } + + const response = await makeRequest({ + method: 'post', + endPoint, + headers, + body + }); + return response; +}; + +const createDirs = async (crowdinDirs, dirPath) => { + // superParent is the top level directory on crowdin + const superParent = crowdinDirs.find(dir => !dir.data.directoryId); + let lastParentId = superParent.data.id; + + const splitDirPath = dirPath.split('/'); + splitDirPath.shift(); + + // we are assuming that the first directory in 'newFile' is the same as the superParent + // maybe throw a check in here to verify that's true + const findCurrDir = (directory, crowdinDirs) => { + return crowdinDirs.find(({ data: { name, directoryId } }) => { + return name === directory && directoryId === lastParentId; + }); + }; + + for (let directory of splitDirPath) { + const currentDirectory = findCurrDir(directory, crowdinDirs); + if (!currentDirectory) { + const response = await addDir(10, directory, lastParentId); + lastParentId = response.data.id; + } else { + lastParentId = currentDirectory.data.id; + } + } + return lastParentId; +}; + +module.exports = { + addDir, + getDirs, + createDirs +}; diff --git a/tools/crowdin/utils/files.js b/tools/crowdin/utils/files.js new file mode 100644 index 0000000000..b2337c8cdc --- /dev/null +++ b/tools/crowdin/utils/files.js @@ -0,0 +1,124 @@ +const makeRequest = require('./make-request'); +const delay = require('./delay'); +const authHeader = require('./auth-header'); + +const addFile = async (projectId, filename, fileContent, directoryId) => { + let headers = { ...authHeader }; + headers['Crowdin-API-FileName'] = filename; + const endPoint = `storages`; + const contentType = 'application/text'; + const body = fileContent; + const storageResponse = await makeRequest({ + method: 'post', + contentType, + endPoint, + headers, + body + }); + if (storageResponse.data) { + const fileBody = { + storageId: storageResponse.data.id, + name: filename, + directoryId + }; + const fileResponse = await makeRequest({ + method: 'post', + endPoint: `projects/${projectId}/files`, + headers, + body: fileBody + }); + if (fileResponse.data) { + return fileResponse.data; + } else { + console.log('error'); + console.dir(fileResponse, { depth: null, colors: true }); + } + } + return null; +}; + +const updateFile = async (projectId, fileId, fileContent) => { + let headers = { ...authHeader }; + const endPoint = `storages`; + const contentType = 'application/text'; + const body = fileContent; + const storageResponse = await makeRequest({ + method: 'post', + contentType, + endPoint, + headers, + body + }); + if (storageResponse.data) { + const fileBody = { + storageId: storageResponse.data.id + }; + const fileResponse = await makeRequest({ + method: 'put', + endPoint: `projects/${projectId}/files${fileId}`, + headers, + body: fileBody + }); + if (fileResponse.data) { + return fileResponse.data; + } else { + console.log('error'); + console.dir(fileResponse, { depth: null, colors: true }); + } + } + return null; +}; + +const deleteFile = async (projectId, fileId, filePath) => { + let headers = { ...authHeader }; + const endPoint = `projects/${projectId}/files/${fileId}`; + await makeRequest({ + method: 'delete', + endPoint, + headers + }); + console.log(`Deleted ${filePath} from Crowdin project`); + return null; +}; + +const getFiles = async projectId => { + let headers = { ...authHeader }; + let done = false; + let offset = 0; + let files = []; + while (!done) { + const endPoint = `projects/${projectId}/files?limit=500&offset=${offset}`; + await delay(1000); + const response = await makeRequest({ + method: 'get', + endPoint, + headers + }); + if (response.data) { + if (response.data.length) { + files = [...files, ...response.data]; + offset += 500; + } else { + done = true; + files = files.map(({ data: { directoryId, id: fileId, path } }) => { + // remove leading forwardslash + path = path.slice(1); + return { directoryId, fileId, path }; + }); + return files; + } + } else { + const { error } = response; + console.log(error.errorcode); + console.log(error.messsage); + } + } + return null; +}; + +module.exports = { + addFile, + updateFile, + deleteFile, + getFiles +}; diff --git a/tools/crowdin/utils/make-request.js b/tools/crowdin/utils/make-request.js new file mode 100644 index 0000000000..37e0af1436 --- /dev/null +++ b/tools/crowdin/utils/make-request.js @@ -0,0 +1,34 @@ +require('dotenv').config(); +const fetch = require('node-fetch'); + +const makeRequest = async ({ + method, + endPoint, + contentType = 'application/json', + accept = 'application/json', + headers, + body +}) => { + headers = { ...headers, 'Content-Type': contentType, Accept: accept }; + const apiUrl = process.env.CROWDIN_API_URL + endPoint; + + if (contentType === 'application/x-www-form-urlencoded') { + body = Object.entries(body) + .reduce((formDataArr, [key, value]) => { + return formDataArr.concat(`${key}=${value}`); + }, []) + .join('&'); + } else if (contentType === 'application/json') { + body = JSON.stringify(body); + } + + const response = await fetch(apiUrl, { headers, method, body }); + if (method !== 'delete') { + const data = await response.json(); + return data; + } else { + return null; + } +}; + +module.exports = makeRequest; diff --git a/tools/crowdin/utils/strings.js b/tools/crowdin/utils/strings.js new file mode 100644 index 0000000000..a0769ec183 --- /dev/null +++ b/tools/crowdin/utils/strings.js @@ -0,0 +1,74 @@ +const authHeader = require('./auth-header'); +const makeRequest = require('./make-request'); + +const isHeading = str => /\h\d/.test(str); +const isCode = str => /^\/pre\/code|\/code$/.test(str); + +const shouldHide = (text, context, challengeTitle) => { + if (isHeading(context) || isCode(context)) { + return true; + } + return text !== challengeTitle && context.includes('id=front-matter'); +}; + +const getStrings = async ({ projectId, fileId }) => { + let headers = { ...authHeader }; + const endPoint = `projects/${projectId}/strings?fileId=${fileId}&limit=500`; + const strings = await makeRequest({ method: 'get', endPoint, headers }); + if (strings.data) { + return strings.data; + } else { + const { error, errors } = strings; + console.error(error ? error : errors); + return null; + } +}; + +const updateString = async ({ projectId, stringId, propsToUpdate }) => { + let headers = { ...authHeader }; + const endPoint = `projects/${projectId}/strings/${stringId}`; + const body = propsToUpdate.map(({ path, value }) => ({ + op: 'replace', + path, + value + })); + await makeRequest({ + method: 'patch', + endPoint, + headers, + body + }); +}; + +const changeHiddenStatus = async (projectId, stringId, newStatus) => { + await updateString({ + projectId, + stringId, + propsToUpdate: [{ path: '/isHidden', value: newStatus }] + }); +}; + +const updateFileStrings = async ({ projectId, fileId, challengeTitle }) => { + const fileStrings = await getStrings({ + projectId, + fileId + }); + + for (let { + data: { id: stringId, text, isHidden, context } + } of fileStrings) { + const hideString = shouldHide(text, context, challengeTitle); + if (!isHidden && hideString) { + changeHiddenStatus(projectId, stringId, true); + } else if (isHidden && !hideString) { + changeHiddenStatus(projectId, stringId, false); + } + } +}; + +module.exports = { + getStrings, + changeHiddenStatus, + updateString, + updateFileStrings +};