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