Skip to content

Commit eda080f

Browse files
authored
Add script and GitHub action to update files in Crowdin (#3657)
1 parent eaa8929 commit eda080f

File tree

5 files changed

+294
-3
lines changed

5 files changed

+294
-3
lines changed

.github/workflows/e2e-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@ jobs:
468468
base64 --decode - <<< "${E2E_SECRETS_JSON_BASE64}" > ./ClientApp/e2e/secrets.json
469469
470470
- name: Set up Deno
471-
uses: denoland/setup-deno@909cc5acb0fdd60627fb858598759246509fa755 # v2.0.2
471+
uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3
472472
with:
473473
deno-version: v2.x
474474
- name: Playwright install browsers and package dependencies
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: "Push files to Crowdin"
2+
permissions:
3+
contents: read
4+
5+
on:
6+
workflow_dispatch:
7+
push:
8+
branches:
9+
- master
10+
paths:
11+
- "src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/checking_en.json"
12+
- "src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json"
13+
- "src/SIL.XForge.Scripture/Resources/SharedResource.resx"
14+
- "src/SIL.XForge.Scripture/Resources/Pages.Index.resx"
15+
- "src/SIL.XForge.Scripture/Resources/Pages.NotFound.resx"
16+
17+
jobs:
18+
push_files_to_crowdin:
19+
name: "Push files to Crowdin"
20+
runs-on: ubuntu-24.04
21+
steps:
22+
- name: "Fail if not on master"
23+
if: github.ref != 'refs/heads/master'
24+
shell: bash
25+
run: |
26+
echo "GITHUB_REF=$GITHUB_REF"
27+
echo "GITHUB_REF_NAME=$GITHUB_REF_NAME"
28+
echo "This workflow must be run on the master branch (got '${GITHUB_REF}')."
29+
exit 1
30+
31+
- name: "Checkout"
32+
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
33+
with:
34+
persist-credentials: false
35+
fetch-depth: 0
36+
37+
- name: "Set up Deno"
38+
uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3
39+
with:
40+
deno-version: v2.x
41+
42+
- name: "Push files to Crowdin"
43+
run: scripts/push_changes_to_crowdin.mts
44+
env:
45+
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
46+
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}

.github/workflows/update-font-list.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
persist-credentials: true
2020

2121
- name: Set up Deno
22-
uses: denoland/setup-deno@909cc5acb0fdd60627fb858598759246509fa755 # v2.0.2
22+
uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3
2323
with:
2424
deno-version: v2.x
2525

.github/workflows/update-localizations.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
ssh-key: ${{ secrets.SF_PUSH_KEY }}
2121

2222
- name: Set up Deno
23-
uses: denoland/setup-deno@909cc5acb0fdd60627fb858598759246509fa755 # v2.0.2
23+
uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3
2424
with:
2525
deno-version: v2.x
2626

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
#!/usr/bin/env -S deno run --allow-net --allow-read --allow-env
2+
3+
const projectId = Deno.env.get('CROWDIN_PROJECT_ID');
4+
const apiKey = Deno.env.get('CROWDIN_API_KEY');
5+
const projectRoot = Deno.cwd();
6+
7+
if (!projectId || !apiKey) {
8+
console.error('CROWDIN_PROJECT_ID and CROWDIN_API_KEY environment variables are required');
9+
Deno.exit(1);
10+
}
11+
12+
async function ensureSuccess(response: Response): Promise<void> {
13+
if (!response.ok) {
14+
const responseBody = await response.text();
15+
throw new Error(
16+
`Request for ${response.url} failed with status code ${response.status}: ${response.statusText}\nResponse body: ${responseBody}`
17+
);
18+
}
19+
}
20+
21+
/** Represents a mapping between a local file and its Crowdin file path & metadata. */
22+
interface CrowdinFile {
23+
localPath: string;
24+
crowdinPath: string;
25+
contentType: string;
26+
fileName: string;
27+
}
28+
29+
/** Describes the status of a single file relative to Crowdin (presence and whether content differs). */
30+
interface FileStatus {
31+
file: CrowdinFile;
32+
exists: boolean;
33+
needsUpdate: boolean;
34+
error?: string;
35+
}
36+
37+
// Files to upload to Crowdin
38+
const filesToUpload: CrowdinFile[] = [
39+
{
40+
localPath: 'src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/checking_en.json',
41+
crowdinPath: '/checking_en.json',
42+
contentType: 'application/json'
43+
},
44+
{
45+
localPath: 'src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json',
46+
crowdinPath: '/non_checking_en.json',
47+
contentType: 'application/json'
48+
},
49+
{
50+
localPath: 'src/SIL.XForge.Scripture/Resources/SharedResource.resx',
51+
crowdinPath: '/SharedResource.resx',
52+
contentType: 'application/xml'
53+
},
54+
{
55+
localPath: 'src/SIL.XForge.Scripture/Resources/Pages.Index.resx',
56+
crowdinPath: '/Pages.Index.resx',
57+
contentType: 'application/xml'
58+
},
59+
{
60+
localPath: 'src/SIL.XForge.Scripture/Resources/Pages.NotFound.resx',
61+
crowdinPath: '/Pages.NotFound.resx',
62+
contentType: 'application/xml'
63+
}
64+
].map(file => {
65+
return {
66+
...file,
67+
fileName: file.crowdinPath.startsWith('/') ? file.crowdinPath.slice(1) : file.crowdinPath
68+
};
69+
});
70+
71+
/** Downloads the current content of a Crowdin file by ID. */
72+
async function downloadFileContent(fileId: number): Promise<string> {
73+
// Get download URL
74+
const response = await fetch(`https://api.crowdin.com/api/v2/projects/${projectId}/files/${fileId}/download`, {
75+
headers: { Authorization: `Bearer ${apiKey}` }
76+
});
77+
await ensureSuccess(response);
78+
const downloadInfo = await response.json();
79+
// Fetch file content
80+
const fileResponse = await fetch(downloadInfo.data.url);
81+
await ensureSuccess(fileResponse);
82+
return await fileResponse.text();
83+
}
84+
85+
/** Uploads file content to Crowdin storage and returns the storage ID. */
86+
async function uploadFileToStorage(file: CrowdinFile, fileContent: string): Promise<number> {
87+
console.log(`Uploading file content to storage for ${file.fileName}...`);
88+
const response = await fetch(`https://api.crowdin.com/api/v2/storages`, {
89+
method: 'POST',
90+
headers: {
91+
Authorization: `Bearer ${apiKey}`,
92+
'Content-Type': file.contentType,
93+
'Crowdin-API-FileName': encodeURIComponent(file.fileName)
94+
},
95+
body: fileContent
96+
});
97+
await ensureSuccess(response);
98+
const data = await response.json();
99+
return data.data.id;
100+
}
101+
102+
/** Updates an existing Crowdin file with new content from storage. */
103+
async function updateExistingFile(fileId: number, storageId: number, fileName: string): Promise<void> {
104+
console.log(`Updating existing file ${fileName}...`);
105+
const response = await fetch(`https://api.crowdin.com/api/v2/projects/${projectId}/files/${fileId}`, {
106+
method: 'PUT',
107+
headers: {
108+
Authorization: `Bearer ${apiKey}`,
109+
'Content-Type': 'application/json'
110+
},
111+
body: JSON.stringify({
112+
storageId: storageId,
113+
updateOption: 'clear_translations_and_approvals'
114+
})
115+
});
116+
await ensureSuccess(response);
117+
console.log(`Successfully updated ${fileName}`);
118+
}
119+
120+
/** Determines if the local file differs from the remote Crowdin version. */
121+
async function checkFileStatus(file: CrowdinFile, existingFiles: Record<string, number>): Promise<FileStatus> {
122+
const fullPath = `${projectRoot}/${file.localPath}`;
123+
if (!existingFiles[file.crowdinPath]) {
124+
return { file, exists: false, needsUpdate: false, error: `File '${file.crowdinPath}' does not exist on Crowdin` };
125+
}
126+
try {
127+
const localContent = await Deno.readTextFile(fullPath);
128+
const crowdinContent = await downloadFileContent(existingFiles[file.crowdinPath]);
129+
const needsUpdate = localContent !== crowdinContent;
130+
return { file, exists: true, needsUpdate };
131+
} catch (error) {
132+
return { file, exists: true, needsUpdate: false, error: `Failed to check file status: ${error}` };
133+
}
134+
}
135+
136+
/** Performs the full upload cycle for a single file (read -> storage -> update existing file). */
137+
async function uploadFile(file: CrowdinFile, existingFiles: Record<string, number>): Promise<boolean> {
138+
const fullPath = `${projectRoot}/${file.localPath}`;
139+
try {
140+
if (!existingFiles[file.crowdinPath]) {
141+
console.error(
142+
`ERROR: File '${file.crowdinPath}' does not exist on Crowdin! This indicates a configuration problem.`
143+
);
144+
console.error(` Local file: ${file.localPath}`);
145+
return false;
146+
}
147+
const fileContent = await Deno.readTextFile(fullPath);
148+
const storageId = await uploadFileToStorage(file, fileContent);
149+
await updateExistingFile(existingFiles[file.crowdinPath], storageId, file.crowdinPath);
150+
return true;
151+
} catch (error) {
152+
console.error(`Failed to upload ${file.localPath}:`, error);
153+
return false;
154+
}
155+
}
156+
157+
/** Retrieves existing Crowdin files and maps path -> file ID. */
158+
async function getExistingFiles(): Promise<Record<string, number>> {
159+
console.log('Fetching existing files from Crowdin...');
160+
const filesPerPage = 500; // max supported by Crowdin API
161+
const response = await fetch(`https://api.crowdin.com/api/v2/projects/${projectId}/files?limit=${filesPerPage}`, {
162+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }
163+
});
164+
await ensureSuccess(response);
165+
const data = await response.json();
166+
const existingFiles: Record<string, number> = {};
167+
for (const file of data.data) {
168+
existingFiles[file.data.path] = file.data.id;
169+
}
170+
171+
// At the time of writing the script, 36 files are fetched, so hitting the limit of 500 is mostly theoretical
172+
if (Object.keys(existingFiles).length >= filesPerPage) {
173+
throw new Error(`Hit pagination limit of ${filesPerPage} files. Please implement pagination handling.`);
174+
}
175+
176+
return existingFiles;
177+
}
178+
179+
console.log('Starting file upload to Crowdin...');
180+
console.log(`Project ID: ${projectId}`);
181+
182+
try {
183+
const existingFiles = await getExistingFiles();
184+
185+
// Check status of all files
186+
console.log('\nChecking file status...');
187+
const fileStatuses: FileStatus[] = [];
188+
189+
for (const file of filesToUpload) {
190+
const status = await checkFileStatus(file, existingFiles);
191+
fileStatuses.push(status);
192+
}
193+
194+
// Print status summary
195+
console.log('\nFile Status Summary:');
196+
console.log('===================');
197+
198+
let hasErrors = false;
199+
const filesToUpdate: FileStatus[] = [];
200+
201+
for (const status of fileStatuses) {
202+
if (status.error) {
203+
console.log(`❌ ${status.file.crowdinPath}: ${status.error}`);
204+
hasErrors = true;
205+
} else if (!status.exists) {
206+
console.log(`❌ ${status.file.crowdinPath}: Missing from Crowdin`);
207+
hasErrors = true;
208+
} else if (status.needsUpdate) {
209+
console.log(`🔄 ${status.file.crowdinPath}: Needs update`);
210+
filesToUpdate.push(status);
211+
} else {
212+
console.log(`✅ ${status.file.crowdinPath}: Up to date`);
213+
}
214+
}
215+
216+
if (hasErrors) {
217+
console.error('\nSome files have errors. Exiting with error status.');
218+
Deno.exit(1);
219+
}
220+
221+
if (filesToUpdate.length === 0) {
222+
console.log('\n🎉 All files are up to date! No updates needed.');
223+
Deno.exit(0);
224+
}
225+
226+
// Update files that need updating
227+
console.log(`\nUpdating ${filesToUpdate.length} file(s)...`);
228+
229+
for (const status of filesToUpdate) {
230+
const success = await uploadFile(status.file, existingFiles);
231+
if (!success) {
232+
hasErrors = true;
233+
}
234+
}
235+
236+
if (hasErrors) {
237+
console.error('Some files failed to upload. Exiting with error status.');
238+
Deno.exit(1);
239+
}
240+
241+
console.log(`\n🎉 Successfully updated ${filesToUpdate.length} file(s) on Crowdin!`);
242+
} catch (error) {
243+
console.error('Failed to upload files to Crowdin:', error);
244+
Deno.exit(1);
245+
}

0 commit comments

Comments
 (0)