feat: Crowdin integration scripts/actions (#40657)

This commit is contained in:
Randell Dawson
2021-01-12 11:20:54 -07:00
committed by GitHub
parent ab222e31e7
commit 0095583028
16 changed files with 670 additions and 69 deletions

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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"
}
]

View File

@ -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'

View File

@ -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);

View File

@ -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'

View File

@ -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);

106
tools/crowdin/package-lock.json generated Normal file
View File

@ -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="
}
}
}

View File

@ -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"
}
}

View File

@ -0,0 +1,7 @@
require('dotenv').config();
const authHeader = {
Authorization: `Bearer ${process.env.CROWDIN_PERSONAL_TOKEN}`
};
module.exports = authHeader;

View File

@ -0,0 +1,5 @@
const delay = (time = 2000) => {
return new Promise(resolve => setTimeout(() => resolve(true), time));
};
module.exports = delay;

View File

@ -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
};

View File

@ -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
};

View File

@ -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;

View File

@ -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
};