Skip to content

Commit db5862e

Browse files
committed
ci: optimize crowdin
1 parent a29e035 commit db5862e

6 files changed

Lines changed: 216 additions & 129 deletions

File tree

script/crowdin/client.ts

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,12 @@ const MAIN_BRANCH_NAME = 'main'
1919
*/
2020
class PaginationIterator<T> {
2121
private offset = 0
22-
private limit = 25
22+
private limit = 500
2323
private isEnd = false
2424
private buf: T[] = []
2525
private cursor = 0
26-
private query: (pagination: Pagination) => Promise<ResponseList<T>>
2726

28-
constructor(query: (pagination: Pagination) => Promise<ResponseList<T>>) {
29-
this.query = query
27+
constructor(private query: (pagination: Pagination) => Promise<ResponseList<T>>) {
3028
}
3129

3230
reset(): void {
@@ -169,17 +167,6 @@ export class CrowdinClient {
169167
return response.data
170168
}
171169

172-
async restoreFile(storage: UploadStorageModel.Storage, existFile: SourceFilesModel.File): Promise<SourceFilesModel.File> {
173-
const response = await this.crowdin.sourceFilesApi.updateOrRestoreFile(PROJECT_ID, existFile.id, { storageId: storage.id })
174-
return response.data
175-
}
176-
177-
getFileByName(param: NameKey): Promise<SourceFilesModel.File | undefined> {
178-
return new PaginationIterator(
179-
p => this.crowdin.sourceFilesApi.listProjectFiles(PROJECT_ID, { ...p, branchId: param.branchId })
180-
).findFirst(t => t.name === param.name)
181-
}
182-
183170
getDirByName(param: NameKey): Promise<SourceFilesModel.Directory | undefined> {
184171
return new PaginationIterator(
185172
p => this.crowdin.sourceFilesApi.listProjectDirectories(PROJECT_ID, { ...p, branchId: param.branchId })
@@ -200,6 +187,12 @@ export class CrowdinClient {
200187
).findAll()
201188
}
202189

190+
async downloadSourceFile(fileId: number): Promise<Record<string, unknown>> {
191+
const res = await this.crowdin.sourceFilesApi.downloadFile(PROJECT_ID, fileId)
192+
const response = await fetch(res.data.url)
193+
return await response.json() as Record<string, unknown>
194+
}
195+
203196
listStringsByFile(fileId: number): Promise<SourceStringsModel.String[]> {
204197
return new PaginationIterator(
205198
p => this.crowdin.sourceStringsApi.listProjectStrings(PROJECT_ID, { ...p, fileId: fileId })
@@ -280,11 +273,24 @@ export class CrowdinClient {
280273
skipUntranslatedStrings: true,
281274
})
282275
const buildId = buildRes.data.id
276+
const maxRetries = 120
277+
let retryCount = 0
283278
while (true) {
284-
// Wait finished
285-
const res = await this.crowdin.translationsApi.downloadTranslations(PROJECT_ID, buildId)
286-
return res.data.url
279+
if (retryCount >= maxRetries) {
280+
throw new Error(`Build timed out after ${maxRetries} retries: buildId=${buildId}`)
281+
}
282+
const statusRes = await this.crowdin.translationsApi.checkBuildStatus(PROJECT_ID, buildId)
283+
const { status, progress } = statusRes.data
284+
console.log(`Build status: ${status}, progress: ${progress}%`)
285+
if (status === 'finished') break
286+
if (status === 'canceled' || status === 'failed') {
287+
throw new Error(`Build ${status}: buildId=${buildId}`)
288+
}
289+
retryCount++
290+
await new Promise(resolve => setTimeout(resolve, 1000))
287291
}
292+
const res = await this.crowdin.translationsApi.downloadTranslations(PROJECT_ID, buildId)
293+
return res.data.url
288294
}
289295
}
290296

script/crowdin/common.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export const RSC_FILE_SUFFIX = "-resource.json"
9292
* @param dir the directory of messages
9393
* @returns
9494
*/
95-
export async function readAllMessages(dir: Dir): Promise<Record<string, Messages<any>>> {
95+
export async function readAllMessages(dir: Dir): Promise<Record<string, Messages<Record<string, unknown>>>> {
9696
const dirPath = path.join(MSG_BASE, dir)
9797

9898
const files = fs.readdirSync(dirPath)
@@ -101,7 +101,7 @@ export async function readAllMessages(dir: Dir): Promise<Record<string, Messages
101101
if (!file.endsWith(RSC_FILE_SUFFIX)) {
102102
return
103103
}
104-
const message = (await import(`@i18n/message/${dir}/${file}`))?.default as Messages<any>
104+
const message = (await import(`@i18n/message/${dir}/${file}`))?.default as Messages<Record<string, unknown>>
105105
const name = file.replace(RSC_FILE_SUFFIX, '')
106106
message && (result[name] = message)
107107
return
@@ -110,10 +110,10 @@ export async function readAllMessages(dir: Dir): Promise<Record<string, Messages
110110
}
111111

112112
/**
113-
* Merge crowdin message into locale resource json files
113+
* Merge crowdin message into locale resource JSON files
114114
*
115115
* @param dir dir
116-
* @param filename the name of json file
116+
* @param filename the name of JSON file
117117
* @param messages crowdin messages
118118
*/
119119
export async function mergeMessage(
@@ -123,7 +123,7 @@ export async function mergeMessage(
123123
): Promise<void> {
124124
const dirPath = path.join(MSG_BASE, dir)
125125
const filePath = path.join(dirPath, filename)
126-
const existMessages = (await import(`@i18n/message/${dir}/${filename}`))?.default as Messages<any>
126+
const existMessages = (await import(`@i18n/message/${dir}/${filename}`))?.default as Messages<Record<string, unknown>>
127127
if (!existMessages) {
128128
logError(`Failed to find local code: dir=${dir}, filename=${filename}`)
129129
return
@@ -155,22 +155,22 @@ export async function mergeMessage(
155155
}
156156

157157
function logError(msg: string) {
158-
console.error(`[CROWDIN-ERROR] ${msg}`)
158+
console.error(`\x1b[31m[CROWDIN-ERROR]\x1b[0m ${msg}`)
159159
}
160160

161161
function checkPlaceholder(translated: string, source: string) {
162162
const allSourcePlaceholders =
163-
Array.from(source.matchAll(/\{(.*?)\}/g))
163+
Array.from(source.matchAll(/\{(.*?)}/g))
164164
.map(matched => matched[1])
165165
.sort()
166166
const allTranslatedPlaceholders =
167-
Array.from(translated.matchAll(/\{(.*?)\}/g))
167+
Array.from(translated.matchAll(/\{(.*?)}/g))
168168
.map(matched => matched[1])
169169
.sort()
170170
if (allSourcePlaceholders.length != allTranslatedPlaceholders.length) {
171171
return false
172172
}
173-
for (let i = 0; i++; i < allSourcePlaceholders.length) {
173+
for (let i = 0; i < allSourcePlaceholders.length; i++) {
174174
if (allSourcePlaceholders[i] !== allTranslatedPlaceholders[i]) {
175175
return false
176176
}
@@ -220,3 +220,5 @@ export async function checkMainBranch(client: CrowdinClient): Promise<SourceFile
220220
const branch = await client.getMainBranch()
221221
return branch ?? exitWith("Main branch is null")
222222
}
223+
224+

script/crowdin/download.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import decompress from "decompress"
2+
import { existsSync, readFileSync } from "fs"
3+
import { rm, writeFile } from "fs/promises"
4+
import { join } from "path"
5+
import { type Dir, type ItemSet, transMsg } from "./common"
6+
7+
const TEMP_FILE_NAME = join(process.cwd(), ".crowdin-temp.zip")
8+
const TEMP_DIR = join(process.cwd(), ".crowdin-temp")
9+
10+
export async function downloadProjectZip(url: string): Promise<string> {
11+
const res = await fetch(url)
12+
const blob = await res.blob()
13+
const buffer = Buffer.from(await blob.arrayBuffer())
14+
await writeFile(TEMP_FILE_NAME, buffer)
15+
console.log("Downloaded project zip file")
16+
if (existsSync(TEMP_DIR)) {
17+
await rm(TEMP_DIR, { recursive: true, force: true })
18+
}
19+
await decompress(TEMP_FILE_NAME, TEMP_DIR)
20+
console.log("Decompressed zip file")
21+
return TEMP_DIR
22+
}
23+
24+
export async function clearTempFile() {
25+
await rm(TEMP_FILE_NAME, { force: true })
26+
await rm(TEMP_DIR, { recursive: true, force: true })
27+
}
28+
29+
/**
30+
* Read a single file from the unzipped Crowdin project translation.
31+
*
32+
* @param tmpDir temp directory containing unzipped translations
33+
* @param langDir language subdirectory (e.g. 'zh-CN' or 'en')
34+
* @param dir message directory
35+
* @param crowdinFileName file name with .json extension
36+
*/
37+
export function readCrowdinZipFile(tmpDir: string, langDir: string, dir: Dir, crowdinFileName: string): ItemSet {
38+
const filePath = join(tmpDir, langDir, dir, crowdinFileName)
39+
if (!existsSync(filePath)) {
40+
return {}
41+
}
42+
try {
43+
const json = readFileSync(filePath).toString()
44+
return transMsg(JSON.parse(json))
45+
} catch (error) {
46+
console.warn(`Failed to parse crowdin zip file: ${filePath}`, error)
47+
return {}
48+
}
49+
}
Lines changed: 11 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,17 @@
1-
import decompress from "decompress"
2-
import { existsSync, readdirSync, readFileSync, rm, writeFile } from "fs"
1+
import { readdirSync, readFileSync } from "fs"
32
import { join } from "path"
43
import { getClientFromEnv } from "./client"
54
import {
6-
ALL_DIRS, ALL_TRANS_LOCALES,
7-
checkMainBranch,
8-
crowdinLangOf,
9-
type Dir,
10-
type ItemSet,
11-
mergeMessage,
12-
RSC_FILE_SUFFIX,
5+
ALL_DIRS, ALL_TRANS_LOCALES, checkMainBranch, crowdinLangOf, type Dir, type ItemSet, mergeMessage, RSC_FILE_SUFFIX,
136
transMsg
147
} from "./common"
8+
import { clearTempFile, downloadProjectZip } from "./download"
159

16-
const TEMP_FILE_NAME = join(process.cwd(), ".crowdin-temp.zip")
17-
const TEMP_DIR = join(process.cwd(), ".crowdin-temp")
18-
19-
async function processDir(dir: Dir): Promise<void> {
10+
async function processDir(tmpDir: string, dir: Dir): Promise<void> {
2011
const fileSets: Record<string, Partial<Record<tt4b.Locale, ItemSet>>> = {}
2112
for (const locale of ALL_TRANS_LOCALES) {
2213
const crowdinLang = crowdinLangOf(locale)
23-
const dirPath = join(TEMP_DIR, crowdinLang, dir)
14+
const dirPath = join(tmpDir, crowdinLang, dir)
2415
const files = readdirSync(dirPath)
2516
for (const fileName of files) {
2617
const json = readFileSync(join(dirPath, fileName)).toString()
@@ -34,44 +25,18 @@ async function processDir(dir: Dir): Promise<void> {
3425
}
3526
}
3627

37-
async function downloadProjectZip(url: string): Promise<void> {
38-
const res = await fetch(url)
39-
const blob = await res.blob()
40-
const buffer = Buffer.from(await blob.arrayBuffer())
41-
await new Promise(resolve => writeFile(TEMP_FILE_NAME, buffer, resolve))
42-
}
43-
44-
async function compressProjectZip(): Promise<void> {
45-
if (existsSync(TEMP_DIR)) {
46-
await new Promise(resolve => rm(TEMP_DIR, { recursive: true }, resolve))
47-
}
48-
await decompress(TEMP_FILE_NAME, TEMP_DIR)
49-
}
50-
51-
async function clearTempFile() {
52-
await new Promise(resolve => rm(TEMP_FILE_NAME, resolve))
53-
await new Promise(resolve => rm(TEMP_DIR, { recursive: true }, resolve))
54-
}
55-
5628
async function main() {
5729
const client = getClientFromEnv()
5830
const branch = await checkMainBranch(client)
5931
const zipUrl = await client.buildProjectTranslation(branch.id)
6032
console.log("Built project translations")
6133
console.log(zipUrl)
62-
await downloadProjectZip(zipUrl)
63-
console.log("Downloaded project zip file")
64-
try {
65-
await compressProjectZip()
66-
console.log("Compressed zip file")
67-
for (const dir of ALL_DIRS) {
68-
await processDir(dir)
69-
console.log("Processed dir: " + dir)
70-
}
71-
} finally {
72-
clearTempFile()
73-
console.log("Cleaned temp files")
34+
const tmpDir = await downloadProjectZip(zipUrl)
35+
36+
for (const dir of ALL_DIRS) {
37+
await processDir(tmpDir, dir)
38+
console.log("Processed dir: " + dir)
7439
}
7540
}
7641

77-
main()
42+
main().finally(clearTempFile)

script/crowdin/sync-source.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
1-
import { type SourceFilesModel, type SourceStringsModel } from "@crowdin/crowdin-api-client"
1+
import type { SourceFilesModel, SourceStringsModel } from "@crowdin/crowdin-api-client"
22
import { toMap } from "@util/array"
33
import { type CrowdinClient, getClientFromEnv, type NameKey } from "./client"
4-
import {
5-
ALL_DIRS,
6-
Dir,
7-
isIgnored,
8-
ItemSet,
9-
readAllMessages,
10-
SOURCE_LOCALE,
11-
transMsg
12-
} from "./common"
4+
import { ALL_DIRS, type Dir, isIgnored, type ItemSet, readAllMessages, SOURCE_LOCALE, transMsg } from "./common"
5+
6+
/**
7+
* Check if local source content is the same as Crowdin source content
8+
*/
9+
function isSourceUnchanged(localContent: ItemSet, crowdinContent: ItemSet): boolean {
10+
const localKeys = Object.keys(localContent).filter(k => !!localContent[k])
11+
const crowdinKeys = Object.keys(crowdinContent)
12+
if (localKeys.length !== crowdinKeys.length) return false
13+
for (const key of localKeys) {
14+
if (localContent[key] !== crowdinContent[key]) return false
15+
}
16+
return true
17+
}
1318

1419
async function initBranch(client: CrowdinClient): Promise<SourceFilesModel.Branch> {
1520
const branch = await client.getOrCreateMainBranch()
@@ -85,6 +90,14 @@ async function processByDir(client: CrowdinClient, dir: Dir, branch: SourceFiles
8590
const storage = await client.createStorage(crowdinFilename, fileContent)
8691
existFile = await client.createFile(directory.id, storage, crowdinFilename)
8792
console.log(`Created new file: dir=${dir}, fileName=${crowdinFilename}, id=${existFile.id}`)
93+
} else {
94+
// Download source file from Crowdin and compare
95+
const crowdinJson = await client.downloadSourceFile(existFile.id)
96+
const crowdinContent = transMsg(crowdinJson)
97+
if (isSourceUnchanged(fileContent, crowdinContent)) {
98+
console.log(`No source diff for ${dir}/${crowdinFilename}, skipped`)
99+
continue
100+
}
88101
}
89102
// Process by strings
90103
await processStrings(client, existFile, fileContent)

0 commit comments

Comments
 (0)