Skip to content

Commit 6c066cc

Browse files
authored
Merge pull request #12698 from ethereum/crowdin-cd
feat: Crowdin import automation
2 parents 2699ee2 + e47e883 commit 6c066cc

File tree

14 files changed

+832
-39
lines changed

14 files changed

+832
-39
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: Import Crowdin translations
2+
3+
on:
4+
schedule:
5+
- cron: "20 16 1 * *" # Runs at 4:20 PM on the first day of every month
6+
workflow_dispatch:
7+
8+
jobs:
9+
create_pr:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- name: Check out code
13+
uses: actions/checkout@v3
14+
15+
- name: Set up Node.js
16+
uses: actions/setup-node@v3
17+
with:
18+
node-version: 18
19+
20+
- name: Install dependencies
21+
run: yarn install
22+
23+
- name: Install ts-node
24+
run: yarn global add ts-node
25+
26+
- name: Set up git
27+
run: |
28+
git config --global user.email "[email protected]"
29+
git config --global user.name "GitHub Action"
30+
31+
- name: Fetch latest dev
32+
run: git fetch origin dev
33+
34+
- name: Get translations
35+
run: npx ts-node -O '{"module":"commonjs"}' ./src/scripts/crowdin/translations/getTranslations.ts
36+
env:
37+
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
38+
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
39+
40+
- name: Authenticate GitHub CLI
41+
run: |
42+
echo ${{ secrets.GITHUB_TOKEN }} | gh auth login --with-token
43+
44+
- name: Process commits and post PRs by language
45+
run: npx ts-node -O '{"module":"commonjs"}' ./src/scripts/crowdin/translations/postLangPRs.ts
46+
env:
47+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,13 @@
7070
"@storybook/react": "7.6.6",
7171
"@storybook/testing-library": "0.2.2",
7272
"@svgr/webpack": "^8.1.0",
73+
"@types/decompress": "^4.2.7",
7374
"@types/hast": "^3.0.0",
7475
"@types/node": "^20.4.2",
7576
"@types/react": "^18.2.15",
7677
"@types/react-dom": "^18.2.7",
7778
"chromatic": "^10.5.0",
79+
"decompress": "^4.2.1",
7880
"eslint": "^8.45.0",
7981
"eslint-config-next": "^13.0.0",
8082
"eslint-config-prettier": "^9.0.0",

src/scripts/crowdin/getTranslationProgress.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from "fs"
22

3+
import { CROWDIN_API_MAX_LIMIT } from "../../lib/constants"
34
import type { ProjectProgressData } from "../../lib/types"
45

56
import crowdin from "./api-client/crowdinClient"
@@ -13,7 +14,7 @@ async function main() {
1314
const response = await crowdin.translationStatusApi.getProjectProgress(
1415
projectId,
1516
{
16-
limit: 200,
17+
limit: CROWDIN_API_MAX_LIMIT,
1718
}
1819
)
1920

src/scripts/crowdin/import/main.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { existsSync, mkdirSync } from "fs"
2+
3+
import { DOT_CROWDIN } from "../translations/constants"
4+
5+
import type { BucketsList, SelectionItem, TrackerObject } from "./types"
6+
import { getImportSelection, handleSummary, processLanguage } from "./utils"
7+
8+
const main = (bucketList: BucketsList) => {
9+
console.log("Bucket list:", bucketList)
10+
11+
// If first time, create directory for user
12+
if (!existsSync(DOT_CROWDIN)) mkdirSync(DOT_CROWDIN)
13+
14+
// Initialize trackers object for summary
15+
const trackers: TrackerObject = { emptyBuckets: 0, langs: {} }
16+
17+
// Filter out empty requests and map selection to usable array
18+
const importSelection: SelectionItem[] = getImportSelection(
19+
bucketList,
20+
trackers
21+
)
22+
23+
// Iterate through each selected language
24+
importSelection.forEach((item) => processLanguage(item, trackers))
25+
26+
handleSummary(importSelection, trackers)
27+
}
28+
29+
export default main

src/scripts/crowdin/import/types.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export type BucketsList = Record<string, number[]>
2+
3+
export type LangTrackerEntry = {
4+
buckets: number[]
5+
jsonCopyCount: number
6+
mdCopyCount: number
7+
error: string
8+
}
9+
10+
export type TrackerObject = {
11+
emptyBuckets: number
12+
langs: Record<string, LangTrackerEntry>
13+
}
14+
15+
export type SelectionItem = {
16+
repoLangCode: string
17+
crowdinLangCode: string
18+
buckets: number[]
19+
}
20+
21+
export type SummaryItem = {
22+
repoLangCode: string
23+
buckets: string[] | number[]
24+
jsonCopyCount: number
25+
mdCopyCount: number
26+
error?: string
27+
}

src/scripts/crowdin/import/utils.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { copyFileSync, existsSync, mkdirSync, readdirSync } from "fs"
2+
import { join } from "path"
3+
4+
import i18Config from "../../../../i18n.config.json"
5+
import { INTL_JSON_DIR, TRANSLATIONS_DIR } from "../../../lib/constants"
6+
import { DOT_CROWDIN } from "../translations/constants"
7+
8+
import { BucketsList, SelectionItem, SummaryItem, TrackerObject } from "./types"
9+
10+
/**
11+
* Some language codes used in the repo differ from those used by Crowdin.
12+
* This is used to convert any codes that may differ when performing folder lookup.
13+
*/
14+
export const getCrowdinCode = (code: string): string =>
15+
i18Config.filter((lang) => lang.code === code)?.[0]?.crowdinCode || code
16+
17+
/**
18+
* Reads `ls` file contents of `path`, moving .md and .json files
19+
* to their corresponding destinations in the repo. Function is called
20+
* again recursively for subdirectories.
21+
*
22+
* @param path An absolute path to the directory being scraped.
23+
* @param contentSubpath The subpath deep to the lang-code directory,
24+
* used to construct destination for markdown content files
25+
* @param repoLangCode Language code used within the repo
26+
* @returns void
27+
*/
28+
export const scrapeDirectory = (
29+
path: string,
30+
contentSubpath: string,
31+
repoLangCode: string,
32+
trackers: TrackerObject
33+
): void => {
34+
if (!existsSync(path)) return
35+
const ls: string[] = readdirSync(path).filter(
36+
(dir: string) => !dir.startsWith(".")
37+
)
38+
ls.forEach((item: string) => {
39+
const source: string = join(path, item)
40+
if (item.endsWith(".json")) {
41+
const jsonDestDirPath: string = join(INTL_JSON_DIR, repoLangCode)
42+
if (!existsSync(jsonDestDirPath))
43+
mkdirSync(jsonDestDirPath, { recursive: true })
44+
const jsonDestinationPath: string = join(jsonDestDirPath, item)
45+
copyFileSync(source, jsonDestinationPath)
46+
// Update .json tracker
47+
trackers.langs[repoLangCode].jsonCopyCount++
48+
} else if (
49+
item.endsWith(".md") ||
50+
item.endsWith(".svg") ||
51+
item.endsWith(".xlsx")
52+
) {
53+
const mdDestDirPath: string = join(
54+
TRANSLATIONS_DIR,
55+
repoLangCode,
56+
contentSubpath
57+
)
58+
if (!existsSync(mdDestDirPath))
59+
mkdirSync(mdDestDirPath, { recursive: true })
60+
const mdDestinationPath: string = join(mdDestDirPath, item)
61+
copyFileSync(source, mdDestinationPath)
62+
// Update .md tracker
63+
trackers.langs[repoLangCode].mdCopyCount++
64+
} else {
65+
// If another directory, recursively call `scrapeDirectory`
66+
scrapeDirectory(
67+
`${path}/${item}`,
68+
`${contentSubpath}/${item}`,
69+
repoLangCode,
70+
trackers
71+
)
72+
}
73+
})
74+
}
75+
76+
export const getImportSelection = (
77+
buckets: BucketsList,
78+
trackers: TrackerObject
79+
): SelectionItem[] =>
80+
Object.keys(buckets)
81+
.filter((repoLangCode: string): boolean => {
82+
if (!buckets[repoLangCode].length) trackers.emptyBuckets++
83+
return !!buckets[repoLangCode].length
84+
})
85+
.map(
86+
(repoLangCode: string): SelectionItem => ({
87+
repoLangCode,
88+
crowdinLangCode: getCrowdinCode(repoLangCode),
89+
buckets: buckets[repoLangCode],
90+
})
91+
)
92+
93+
/**
94+
* ./postLangPRs.ts
95+
*/
96+
97+
export const processBucket = (
98+
bucket: number,
99+
crowdinLangCode: string,
100+
repoLangCode: string,
101+
langLs: string[],
102+
trackers: TrackerObject
103+
): void => {
104+
const paddedBucket: string = bucket.toString().padStart(2, "0")
105+
let bucketDirName = ""
106+
langLs.forEach((bucketName: string) => {
107+
bucketDirName += bucketName.startsWith(paddedBucket) ? bucketName : ""
108+
})
109+
const bucketDirectoryPath: string = `${DOT_CROWDIN}/${crowdinLangCode}/${bucketDirName}`
110+
// Initial scrapeDirectory function call
111+
scrapeDirectory(bucketDirectoryPath, ".", repoLangCode, trackers)
112+
// Update tracker
113+
trackers.langs[repoLangCode].buckets.push(bucket)
114+
}
115+
116+
export const processLanguage = (
117+
{ repoLangCode, crowdinLangCode, buckets }: SelectionItem,
118+
trackers: TrackerObject
119+
): void => {
120+
// Initialize tracker for language
121+
trackers.langs[repoLangCode] = {
122+
buckets: [],
123+
jsonCopyCount: 0,
124+
mdCopyCount: 0,
125+
error: "",
126+
}
127+
// Initialize working directory and check for existence
128+
const path: string = join(DOT_CROWDIN, crowdinLangCode)
129+
if (!existsSync(path)) {
130+
trackers.langs[
131+
repoLangCode
132+
].error = `Path doesn't exist for lang ${crowdinLangCode}`
133+
return
134+
}
135+
const langLs: string[] = readdirSync(path)
136+
// Iterate over each selected bucket, scraping contents with `scrapeDirectory`
137+
buckets.forEach((bucket) =>
138+
processBucket(bucket, crowdinLangCode, repoLangCode, langLs, trackers)
139+
)
140+
}
141+
142+
export const handleSummary = (
143+
selection: SelectionItem[],
144+
trackers: TrackerObject
145+
) => {
146+
// Construct console summary
147+
const summary: SummaryItem[] = selection.map(
148+
(item: SelectionItem): SummaryItem => {
149+
const { buckets, repoLangCode } = item
150+
const { jsonCopyCount, mdCopyCount, error } = trackers.langs[repoLangCode]
151+
return {
152+
repoLangCode,
153+
buckets,
154+
jsonCopyCount,
155+
mdCopyCount,
156+
error,
157+
}
158+
}
159+
)
160+
161+
// Print summary logs
162+
if (!summary.length) {
163+
console.warn(
164+
"Nothing imported, see instruction at top of crowdin-imports.ts"
165+
)
166+
return
167+
}
168+
console.table(summary)
169+
console.log("🎉 Crowdin import complete.")
170+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { resolve } from "path"
2+
3+
export const DOT_CROWDIN = ".crowdin"
4+
5+
export const CROWDIN_DATA_DIR = "src/data/crowdin"
6+
export const SAVE_FILE = "download.zip"
7+
export const FILE_PATH = resolve(CROWDIN_DATA_DIR, SAVE_FILE)
8+
9+
export const SUMMARY_SAVE_FILE = "import-summary.json"
10+
export const SUMMARY_PATH = resolve(CROWDIN_DATA_DIR, SUMMARY_SAVE_FILE)
11+
12+
export const BUCKETS_IMPORTED_FILE = "buckets-imported.json"
13+
export const BUCKETS_PATH = resolve(CROWDIN_DATA_DIR, BUCKETS_IMPORTED_FILE)
14+
15+
export const APPROVAL_THRESHOLD = 100
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import i18n from "../../../../i18n.config.json"
2+
import bucketDirs from "../../../data/crowdin/translation-buckets-dirs.json"
3+
import { CROWDIN_API_MAX_LIMIT } from "../../../lib/constants"
4+
import crowdin from "../api-client/crowdinClient"
5+
import type { BucketsList } from "../import/types"
6+
7+
import { APPROVAL_THRESHOLD } from "./constants"
8+
9+
async function getApprovedBuckets(): Promise<BucketsList> {
10+
const projectId = Number(process.env.CROWDIN_PROJECT_ID) || 363359
11+
12+
const bucketsList: BucketsList = {}
13+
14+
// TODO: Consider regenerating bucketDirs list on each run for fidelity
15+
for (const bucketDir of bucketDirs) {
16+
const directoryProgress =
17+
await crowdin.translationStatusApi.getDirectoryProgress(
18+
projectId,
19+
bucketDir.id,
20+
{ limit: CROWDIN_API_MAX_LIMIT }
21+
)
22+
23+
const onlyApproved = directoryProgress.data.filter(
24+
({ data: { approvalProgress } }) => approvalProgress >= APPROVAL_THRESHOLD
25+
)
26+
27+
for (const { code, crowdinCode } of i18n) {
28+
const match = onlyApproved.find(
29+
({ data: { languageId } }) => languageId === crowdinCode
30+
)
31+
if (!match) continue
32+
33+
if (!bucketsList[code]) bucketsList[code] = []
34+
35+
const n = parseInt(bucketDir.name.substring(0, 2))
36+
bucketsList[code].push(n)
37+
}
38+
}
39+
40+
return bucketsList
41+
}
42+
43+
export default getApprovedBuckets

0 commit comments

Comments
 (0)