|
| 1 | +import fs from "fs" |
| 2 | +import { dirname } from "path" |
| 3 | +import PO from "pofile" |
| 4 | +import https from "https" |
| 5 | +import glob from "glob" |
| 6 | +import { format as formatDate } from "date-fns" |
| 7 | + |
| 8 | +const getCreateHeaders = (language) => ({ |
| 9 | + "POT-Creation-Date": formatDate(new Date(), "yyyy-MM-dd HH:mmxxxx"), |
| 10 | + "MIME-Version": "1.0", |
| 11 | + "Content-Type": "text/plain; charset=utf-8", |
| 12 | + "Content-Transfer-Encoding": "8bit", |
| 13 | + "X-Generator": "@lingui/cli", |
| 14 | + Language: language, |
| 15 | +}) |
| 16 | + |
| 17 | +// Main sync method, call "Init" or "Sync" depending on the project context |
| 18 | +export default function syncProcess(config, options) { |
| 19 | + if (config.format != 'po') { |
| 20 | + console.error(`\n----------\nTranslation.io service is only compatible with the "po" format. Please update your Lingui configuration accordingly.\n----------`) |
| 21 | + process.exit(1) |
| 22 | + } |
| 23 | + |
| 24 | + const successCallback = (project) => { |
| 25 | + console.log(`\n----------\nProject successfully synchronized. Please use this URL to translate: ${project.url}\n----------`) |
| 26 | + } |
| 27 | + |
| 28 | + const failCallback = (errors) => { |
| 29 | + console.error(`\n----------\nSynchronization with Translation.io failed: ${errors.join(', ')}\n----------`) |
| 30 | + } |
| 31 | + |
| 32 | + init(config, options, successCallback, (errors) => { |
| 33 | + if (errors.length && errors[0] === 'This project has already been initialized.') { |
| 34 | + sync(config, options, successCallback, failCallback) |
| 35 | + } |
| 36 | + else { |
| 37 | + failCallback(errors) |
| 38 | + } |
| 39 | + }) |
| 40 | +} |
| 41 | + |
| 42 | +// Initialize project with source and existing translations (only first time!) |
| 43 | +// Cf. https://translation.io/docs/create-library#initialization |
| 44 | +function init(config, options, successCallback, failCallback) { |
| 45 | + const sourceLocale = config.sourceLocale || 'en' |
| 46 | + const targetLocales = config.locales.filter((value) => value != sourceLocale) |
| 47 | + const paths = poPathsPerLocale(config) |
| 48 | + |
| 49 | + let segments = {} |
| 50 | + |
| 51 | + targetLocales.forEach((targetLocale) => { |
| 52 | + segments[targetLocale] = [] |
| 53 | + }) |
| 54 | + |
| 55 | + // Create segments from source locale PO items |
| 56 | + paths[sourceLocale].forEach((path) => { |
| 57 | + let raw = fs.readFileSync(path).toString() |
| 58 | + let po = PO.parse(raw) |
| 59 | + |
| 60 | + po.items.filter((item) => !item['obsolete']).forEach((item) => { |
| 61 | + targetLocales.forEach((targetLocale) => { |
| 62 | + let newSegment = createSegmentFromPoItem(item) |
| 63 | + |
| 64 | + segments[targetLocale].push(newSegment) |
| 65 | + }) |
| 66 | + }) |
| 67 | + }) |
| 68 | + |
| 69 | + // Add translations to segments from target locale PO items |
| 70 | + targetLocales.forEach((targetLocale) => { |
| 71 | + paths[targetLocale].forEach((path) => { |
| 72 | + let raw = fs.readFileSync(path).toString() |
| 73 | + let po = PO.parse(raw) |
| 74 | + |
| 75 | + po.items.filter((item) => !item['obsolete']).forEach((item, index) => { |
| 76 | + segments[targetLocale][index].target = item.msgstr[0] |
| 77 | + }) |
| 78 | + }) |
| 79 | + }) |
| 80 | + |
| 81 | + let request = { |
| 82 | + "client": "lingui", |
| 83 | + "version": require('@lingui/core/package.json').version, |
| 84 | + "source_language": sourceLocale, |
| 85 | + "target_languages": targetLocales, |
| 86 | + "segments": segments |
| 87 | + } |
| 88 | + |
| 89 | + postTio("init", request, config.service.apiKey, (response) => { |
| 90 | + if (response.errors) { |
| 91 | + failCallback(response.errors) |
| 92 | + } |
| 93 | + else { |
| 94 | + saveSegmentsToTargetPos(config, paths, response.segments) |
| 95 | + successCallback(response.project) |
| 96 | + } |
| 97 | + }, (error) => { |
| 98 | + console.error(`\n----------\nSynchronization with Translation.io failed: ${error}\n----------`) |
| 99 | + }) |
| 100 | +} |
| 101 | + |
| 102 | +// Send all source text from PO to Translation.io and create new PO based on received translations |
| 103 | +// Cf. https://translation.io/docs/create-library#synchronization |
| 104 | +function sync(config, options, successCallback, failCallback) { |
| 105 | + const sourceLocale = config.sourceLocale || 'en' |
| 106 | + const targetLocales = config.locales.filter((value) => value != sourceLocale) |
| 107 | + const paths = poPathsPerLocale(config) |
| 108 | + |
| 109 | + let segments = [] |
| 110 | + |
| 111 | + // Create segments with correct source |
| 112 | + paths[sourceLocale].forEach((path) => { |
| 113 | + let raw = fs.readFileSync(path).toString() |
| 114 | + let po = PO.parse(raw) |
| 115 | + |
| 116 | + po.items.filter((item) => !item['obsolete']).forEach((item) => { |
| 117 | + let newSegment = createSegmentFromPoItem(item) |
| 118 | + |
| 119 | + segments.push(newSegment) |
| 120 | + }) |
| 121 | + }) |
| 122 | + |
| 123 | + let request = { |
| 124 | + "client": "lingui", |
| 125 | + "version": require('@lingui/core/package.json').version, |
| 126 | + "source_language": sourceLocale, |
| 127 | + "target_languages": targetLocales, |
| 128 | + "segments": segments |
| 129 | + } |
| 130 | + |
| 131 | + // Sync and then remove unused segments (not present in the local application) from Translation.io |
| 132 | + if (options.clean) { |
| 133 | + request['purge'] = true |
| 134 | + } |
| 135 | + |
| 136 | + postTio("sync", request, config.service.apiKey, (response) => { |
| 137 | + if (response.errors) { |
| 138 | + failCallback(response.errors) |
| 139 | + } |
| 140 | + else { |
| 141 | + saveSegmentsToTargetPos(config, paths, response.segments) |
| 142 | + successCallback(response.project) |
| 143 | + } |
| 144 | + }, (error) => { |
| 145 | + console.error(`\n----------\nSynchronization with Translation.io failed: ${error}\n----------`) |
| 146 | + }) |
| 147 | +} |
| 148 | + |
| 149 | +function createSegmentFromPoItem(item) { |
| 150 | + let itemHasId = item.msgid != item.msgstr[0] && item.msgstr[0].length |
| 151 | + |
| 152 | + let segment = { |
| 153 | + type: 'source', // No way to edit text for source language (inside code), so not using "key" here |
| 154 | + source: itemHasId ? item.msgstr[0] : item.msgid, // msgstr may be empty if --overwrite is used and no ID is used |
| 155 | + context: '', |
| 156 | + references: [], |
| 157 | + comment: '' |
| 158 | + } |
| 159 | + |
| 160 | + if (itemHasId) { |
| 161 | + segment.context = item.msgid |
| 162 | + } |
| 163 | + |
| 164 | + if (item.references.length) { |
| 165 | + segment.references = item.references |
| 166 | + } |
| 167 | + |
| 168 | + if (item.extractedComments.length) { |
| 169 | + segment.comment = item.extractedComments.join(' | ') |
| 170 | + } |
| 171 | + |
| 172 | + return segment |
| 173 | +} |
| 174 | + |
| 175 | +function createPoItemFromSegment(segment) { |
| 176 | + let item = new PO.Item() |
| 177 | + |
| 178 | + item.msgid = segment.context ? segment.context : segment.source |
| 179 | + item.msgstr = [segment.target] |
| 180 | + item.references = (segment.references && segment.references.length) ? segment.references : [] |
| 181 | + item.extractedComments = segment.comment ? segment.comment.split(' | ') : [] |
| 182 | + |
| 183 | + return item |
| 184 | +} |
| 185 | + |
| 186 | +function saveSegmentsToTargetPos(config, paths, segmentsPerLocale) { |
| 187 | + const NAME = "{name}" |
| 188 | + const LOCALE = "{locale}" |
| 189 | + |
| 190 | + Object.keys(segmentsPerLocale).forEach((targetLocale) => { |
| 191 | + // Remove existing target POs and JS for this target locale |
| 192 | + paths[targetLocale].forEach((path) => { |
| 193 | + const jsPath = path.replace(/\.po?$/, "") + ".js" |
| 194 | + const dirPath = dirname(path) |
| 195 | + |
| 196 | + // Remove PO, JS and empty dir |
| 197 | + if (fs.existsSync(path)) { fs.unlinkSync(path) } |
| 198 | + if (fs.existsSync(jsPath)) { fs.unlinkSync(jsPath) } |
| 199 | + if (fs.existsSync(dirPath) && fs.readdirSync(dirPath).length === 0) { fs.rmdirSync(dirPath) } |
| 200 | + }) |
| 201 | + |
| 202 | + // Find target path (ignoring {name}) |
| 203 | + const localePath = "".concat(config.catalogs[0].path.replace(LOCALE, targetLocale).replace(NAME, ''), ".po") |
| 204 | + const segments = segmentsPerLocale[targetLocale] |
| 205 | + |
| 206 | + let po = new PO() |
| 207 | + po.headers = getCreateHeaders(targetLocale) |
| 208 | + |
| 209 | + let items = [] |
| 210 | + |
| 211 | + segments.forEach((segment) => { |
| 212 | + let item = createPoItemFromSegment(segment) |
| 213 | + items.push(item) |
| 214 | + }) |
| 215 | + |
| 216 | + // Sort items by messageId |
| 217 | + po.items = items.sort((a, b) => { |
| 218 | + if (a.msgid < b.msgid) { return -1 } |
| 219 | + if (a.msgid > b.msgid) { return 1 } |
| 220 | + return 0 |
| 221 | + }) |
| 222 | + |
| 223 | + // Check that localePath directory exists and save PO file |
| 224 | + fs.promises.mkdir(dirname(localePath), {recursive: true}).then(() => { |
| 225 | + po.save(localePath, (err) => { |
| 226 | + if (err) { |
| 227 | + console.error('Error while saving target PO files:') |
| 228 | + console.error(err) |
| 229 | + process.exit(1) |
| 230 | + } |
| 231 | + }) |
| 232 | + }) |
| 233 | + }) |
| 234 | +} |
| 235 | + |
| 236 | +function poPathsPerLocale(config) { |
| 237 | + const NAME = "{name}" |
| 238 | + const LOCALE = "{locale}" |
| 239 | + const paths = [] |
| 240 | + |
| 241 | + config.locales.forEach((locale) => { |
| 242 | + paths[locale] = [] |
| 243 | + |
| 244 | + config.catalogs.forEach((catalog) => { |
| 245 | + const path = "".concat(catalog.path.replace(LOCALE, locale).replace(NAME, "*"), ".po") |
| 246 | + |
| 247 | + // If {name} is present (replaced by *), list all the existing POs |
| 248 | + if (path.includes('*')) { |
| 249 | + paths[locale] = paths[locale].concat(glob.sync(path)) |
| 250 | + } |
| 251 | + else { |
| 252 | + paths[locale].push(path) |
| 253 | + } |
| 254 | + }) |
| 255 | + }) |
| 256 | + |
| 257 | + return paths |
| 258 | +} |
| 259 | + |
| 260 | +function postTio(action, request, apiKey, successCallback, failCallback) { |
| 261 | + let jsonRequest = JSON.stringify(request) |
| 262 | + |
| 263 | + let options = { |
| 264 | + hostname: 'translation.io', |
| 265 | + path: '/api/v1/segments/' + action + '.json?api_key=' + apiKey, |
| 266 | + method: 'POST', |
| 267 | + headers: { |
| 268 | + 'Content-Type': 'application/json', |
| 269 | + } |
| 270 | + } |
| 271 | + |
| 272 | + let req = https.request(options, (res) => { |
| 273 | + res.setEncoding('utf8') |
| 274 | + |
| 275 | + let body = "" |
| 276 | + |
| 277 | + res.on('data', (chunk) => { |
| 278 | + body = body.concat(chunk) |
| 279 | + }) |
| 280 | + |
| 281 | + res.on('end', () => { |
| 282 | + let response = JSON.parse(body) |
| 283 | + successCallback(response) |
| 284 | + }) |
| 285 | + }) |
| 286 | + |
| 287 | + req.on('error', (e) => { |
| 288 | + failCallback(e) |
| 289 | + }) |
| 290 | + |
| 291 | + req.write(jsonRequest) |
| 292 | + req.end() |
| 293 | +} |
0 commit comments