adapt-project 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,495 @@
1
+ import path from 'path'
2
+ import _ from 'lodash'
3
+ import fs from 'fs-extra'
4
+ import csv from 'csv'
5
+ import { XMLParser } from 'fast-xml-parser'
6
+ import async from 'async'
7
+ import globs from 'globs'
8
+ import jschardet from 'jschardet'
9
+ import iconv from 'iconv-lite'
10
+ import Data from './Data.js'
11
+ import Schemas from './Schemas.js'
12
+
13
+ /**
14
+ * @typedef {import('./Framework')} Framework
15
+ */
16
+
17
+ /**
18
+ * Pulls together schemas and data to enable both the export and import of data item attributes
19
+ * marked by the schemas as translatable.
20
+ */
21
+ class Translate {
22
+ /**
23
+ * @param {Object} options
24
+ * @param {Framework} options.framework
25
+ * @param {function} options.includedFilter
26
+ * @param {string} options.masterLang
27
+ * @param {string} options.targetLang
28
+ * @param {string} options.format
29
+ * @param {string} options.csvDelimiter
30
+ * @param {boolean} options.shouldReplaceExisting
31
+ * @param {string} options.jsonext
32
+ * @param {string} options.sourcePath
33
+ * @param {string} options.languagePath
34
+ * @param {string} options.outputPath
35
+ * @param {boolean} options.isTest
36
+ * @param {function} options.log
37
+ */
38
+ constructor ({
39
+ framework = null,
40
+ includedFilter = function () { return true },
41
+ masterLang = 'en',
42
+ targetLang = null,
43
+ format = 'csv',
44
+ csvDelimiter = null,
45
+ shouldReplaceExisting = false,
46
+ jsonext = 'json',
47
+ sourcePath = '',
48
+ languagePath = path.join(process.cwd(), 'languagefiles'),
49
+ outputPath = '',
50
+ courseDir = 'course',
51
+ useOutputData = false,
52
+ isTest = false,
53
+ log = console.log,
54
+ warn = console.warn
55
+ } = {}) {
56
+ /** @type {Framework} */
57
+ this.framework = framework
58
+ /** @type {function} */
59
+ this.includedFilter = includedFilter
60
+ /** @type {string} */
61
+ this.masterLang = masterLang
62
+ /** @type {string} */
63
+ this.targetLang = targetLang
64
+ // format can be raw, json or csv
65
+ /** @type {string} */
66
+ this.format = format
67
+ /** @type {string} */
68
+ this.csvDelimiter = csvDelimiter
69
+ /** @type {boolean} */
70
+ this.shouldReplaceExisting = shouldReplaceExisting
71
+ /** @type {string} */
72
+ this.jsonext = jsonext
73
+ /** @type {string} */
74
+ this.sourcePath = sourcePath.replace(/\\/g, '/')
75
+ /** @type {string} */
76
+ this.outputPath = outputPath.replace(/\\/g, '/')
77
+ /** @type {string} */
78
+ this.courseDir = courseDir
79
+ /** @type {Framework} */
80
+ this.useOutputData = useOutputData
81
+ /** @type {string} */
82
+ this.languagePath = languagePath.replace(/\\/g, '/')
83
+ /** @type {Data} */
84
+ this.data = null
85
+ /** @type {boolean} */
86
+ this.isTest = isTest
87
+ /** @type {function} */
88
+ this.log = log
89
+ /** @type {function} */
90
+ this.warn = warn
91
+ }
92
+
93
+ /** @returns {Translate} */
94
+ load () {
95
+ this.data = new Data({
96
+ framework: this.framework,
97
+ sourcePath: this.useOutputData ? this.outputPath : this.sourcePath,
98
+ courseDir: this.courseDir,
99
+ jsonext: this.jsonext,
100
+ trackingIdType: this.framework.trackingIdType,
101
+ log: this.log
102
+ })
103
+ this.data.load()
104
+ return this
105
+ }
106
+
107
+ /**
108
+ * Produces a single JSON file or a series of CSV files representing all of the
109
+ * files and file items in the data structure.
110
+ * @returns {Translate}
111
+ */
112
+ async export () {
113
+ const schemas = new Schemas({ framework: this.framework, includedFilter: this.includedFilter, sourcePath: this.sourcePath })
114
+ schemas.load()
115
+
116
+ const exportTextData = []
117
+
118
+ // collection translatable texts
119
+ const language = this.data.getLanguage(this.masterLang)
120
+ language.getAllFileItems().forEach(({ file, item }) => {
121
+ const applicableSchemas = schemas.getSchemasForModelJSON(item)
122
+ const translatablePaths = applicableSchemas.getTranslatablePaths()
123
+
124
+ function recursiveJSONProcess (data, level, path, lookupPath, id, file, component) {
125
+ if (level === 0) {
126
+ // at the root
127
+ id = Object.prototype.hasOwnProperty.call(data, '_id') ? data._id : null
128
+ component = Object.prototype.hasOwnProperty.call(data, '_component') ? data._component : null
129
+ }
130
+ if (Array.isArray(data)) {
131
+ for (let i = 0; i < data.length; i++) {
132
+ recursiveJSONProcess(data[i], level += 1, path + i + '/', lookupPath, id, file, component)
133
+ }
134
+ return
135
+ }
136
+ if (typeof data === 'object') {
137
+ for (const attribute in data) {
138
+ recursiveJSONProcess(data[attribute], level += 1, path + attribute + '/', lookupPath + attribute + '/', id, file, component)
139
+ }
140
+ return
141
+ }
142
+ if (data && translatablePaths.includes(lookupPath)) {
143
+ exportTextData.push({
144
+ file,
145
+ id,
146
+ path,
147
+ value: data
148
+ })
149
+ }
150
+ }
151
+
152
+ const filename = path.parse(file.path).name.split('.')[0]
153
+ recursiveJSONProcess(item, 0, '/', '/', null, filename, null)
154
+ })
155
+
156
+ // maintain order with original translate tasks
157
+ const typeSortLevel = {
158
+ course: 1,
159
+ contentObjects: 2,
160
+ articles: 3,
161
+ blocks: 4,
162
+ components: 5
163
+ }
164
+ exportTextData.sort((a, b) => {
165
+ const typeSort = ((typeSortLevel[a.file] || 100) - (typeSortLevel[b.file] || 100))
166
+ return typeSort || a.id.length - b.id.length || a.id.localeCompare(b.id)
167
+ })
168
+
169
+ // output based upon format options
170
+ const outputFolder = path.join(this.languagePath, this.masterLang)
171
+ fs.mkdirpSync(outputFolder)
172
+
173
+ if (this.format === 'json' || this.format === 'raw') {
174
+ const filePath = path.join(outputFolder, 'export.json')
175
+ this.log(`Exporting json to ${filePath}`)
176
+ fs.writeJSONSync(filePath, exportTextData, { spaces: 2 })
177
+ return this
178
+ }
179
+
180
+ if (['xliff', 'xlf'].includes(this.format)) {
181
+ // create csv for each file
182
+ const outputGroupedByFile = exportTextData.reduce((prev, current) => {
183
+ if (!Object.prototype.hasOwnProperty.call(prev, current.file)) {
184
+ prev[current.file] = []
185
+ }
186
+ prev[current.file].push(current)
187
+ return prev
188
+ }, {})
189
+
190
+ // xliff 2.0
191
+ // const output = `<?xml version="1.0" encoding="UTF-8"?>
192
+ // <xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="${this.masterLang}" trgLang="${this.masterLang}">
193
+ // ${Object.entries(outputGroupedByFile).map(([fileName, entries]) => {
194
+ // return ` <file id="${fileName}">
195
+ // ${entries.map(item => {
196
+ // const value = /[<>&"'/]/.test(item.value)
197
+ // ? `<![CDATA[${item.value}]]>`
198
+ // : item.value;
199
+ // return ` <unit id="${item.id}${item.path}">
200
+ // <segment>
201
+ // <source xml:space="preserve">${value}</source>
202
+ // <target xml:space="preserve">${value}</target>
203
+ // </segment>
204
+ // </unit>
205
+ // `;
206
+ // }).filter(Boolean).join('')} </file>
207
+ // `;
208
+ // }).join('')}</xliff>`;
209
+
210
+ // xliff 1.2
211
+ const output = `<?xml version="1.0" encoding="UTF-8"?>
212
+ <xliff version="1.2">
213
+ ${Object.entries(outputGroupedByFile).map(([fileName, entries]) => {
214
+ return ` <file original="${fileName}" source-language="${this.masterLang}" target-language="${this.masterLang}"><body>
215
+ ${entries.map(item => {
216
+ const value = /[<>&"'/]/.test(item.value)
217
+ ? `<![CDATA[${item.value}]]>`
218
+ : item.value
219
+ return ` <trans-unit id="${item.id}${item.path}">
220
+ <source>${value}</source>
221
+ <target>${value}</target>
222
+ </trans-unit>
223
+ `
224
+ }).filter(Boolean).join('')} </body></file>
225
+ `
226
+ }).join('')}</xliff>`
227
+ const filePath = path.join(outputFolder, 'source.xlf')
228
+ this.log(`Exporting xliff to ${filePath}`)
229
+ fs.writeFileSync(filePath, `${output}`)
230
+ return this
231
+ }
232
+
233
+ // create csv for each file
234
+ const outputGroupedByFile = exportTextData.reduce((prev, current) => {
235
+ if (!Object.prototype.hasOwnProperty.call(prev, current.file)) {
236
+ prev[current.file] = []
237
+ }
238
+ prev[current.file].push([`${current.file}/${current.id}${current.path}`, current.value])
239
+ return prev
240
+ }, {})
241
+
242
+ const csvOptions = {
243
+ quotedString: true,
244
+ delimiter: this.csvDelimiter || ','
245
+ }
246
+
247
+ const fileNames = Object.keys(outputGroupedByFile)
248
+ await async.each(fileNames, (name, done) => {
249
+ csv.stringify(outputGroupedByFile[name], csvOptions, (error, output) => {
250
+ if (error) {
251
+ return done(new Error('Error saving CSV files.'))
252
+ }
253
+ const filePath = path.join(outputFolder, `${name}.csv`)
254
+ this.log(`Exporting csv to ${filePath}`)
255
+ fs.writeFileSync(filePath, `\ufeff${output}`)
256
+ done(null)
257
+ })
258
+ })
259
+
260
+ return this
261
+ }
262
+
263
+ /**
264
+ * Imports a single JSON file or multiple CSV files to replace values in the
265
+ * existing data file items.
266
+ * @returns {Translate}
267
+ */
268
+ async import () {
269
+ if (this.isTest) {
270
+ this.log('!TEST IMPORT, not changing data.')
271
+ }
272
+
273
+ // check that a targetLang has been specified
274
+ if (!this.targetLang) {
275
+ const err = new Error('Target language option is missing. ')
276
+ err.number = 10001
277
+ throw err
278
+ }
279
+
280
+ // check input folder exists
281
+ const inputFolder = path.join(this.languagePath, this.targetLang)
282
+ if (!fs.existsSync(inputFolder) || !fs.statSync(inputFolder).isDirectory()) {
283
+ const err = new Error(`Folder does not exist. ${inputFolder}`)
284
+ err.number = 10002
285
+ throw err
286
+ }
287
+
288
+ // auto-detect format if not specified
289
+ let format = this.format
290
+ if (!format) {
291
+ const filePaths = globs.sync([`${inputFolder}/*.*`])
292
+ const uniqueFileExtensions = _.uniq(filePaths.map(filePath => path.extname(filePath).slice(1)))
293
+ if (uniqueFileExtensions.length !== 1) {
294
+ throw new Error(`Format autodetection failed, ${uniqueFileExtensions.length} file types found.`)
295
+ }
296
+ format = uniqueFileExtensions[0]
297
+ switch (format) {
298
+ case 'xlf':
299
+ case 'xliff':
300
+ case 'csv':
301
+ case 'json':
302
+ this.log(`Format autodetected as ${format}`)
303
+ break
304
+ default:
305
+ throw new Error(`Format of the language file is not supported: ${format}`)
306
+ }
307
+ }
308
+
309
+ if (format === 'xliff') format = 'xlf'
310
+
311
+ // discover import files
312
+ const langFiles = globs.sync([`${inputFolder}/*.${format}`])
313
+ if (langFiles.length === 0) {
314
+ throw new Error(`No languagefiles found to process in folder ${inputFolder}`)
315
+ }
316
+
317
+ // copy master language files to target language directory if needed
318
+ if (this.targetLang !== this.masterLang) {
319
+ this.data.copyLanguage(this.masterLang, this.targetLang, this.shouldReplaceExisting)
320
+ }
321
+
322
+ // get target language data
323
+ const targetLanguage = this.data.getLanguage(this.targetLang)
324
+
325
+ if (this.targetLang === this.masterLang && !this.shouldReplaceExisting) {
326
+ const err = new Error(`Folder already exists. ${targetLanguage.path}`)
327
+ err.number = 10003
328
+ throw err
329
+ }
330
+
331
+ // process import files
332
+ let importData
333
+ switch (format) {
334
+ case 'json':
335
+ importData = fs.readJSONSync(langFiles[0])
336
+ break
337
+ case 'xliff':
338
+ case 'xlf': {
339
+ importData = []
340
+ await async.each(langFiles, (filename, done) => {
341
+ const XMLData = fs.readFileSync(filename)
342
+ const parser = new XMLParser({
343
+ ignoreAttributes: false,
344
+ attributeNamePrefix: ''
345
+ })
346
+ const xml = parser.parse(XMLData)
347
+ // xliff 2.0
348
+ // for (const file of xml.xliff.file) {
349
+ // for (const unit of file.unit) {
350
+ // const [ id, ...path ] = unit.id.split('/');
351
+ // importData.push({
352
+ // file: file.id,
353
+ // id,
354
+ // path: path.filter(Boolean).join('/'),
355
+ // value: unit.segment.target['#text']
356
+ // });
357
+ // }
358
+ // }
359
+ // xliff 1.2
360
+ for (const file of xml.xliff.file) {
361
+ for (const unit of file.body['trans-unit']) {
362
+ const [id, ...path] = unit.id.split('/')
363
+ importData.push({
364
+ file: file.original,
365
+ id,
366
+ path: path.filter(Boolean).join('/'),
367
+ value: unit.source
368
+ })
369
+ }
370
+ }
371
+ done()
372
+ })
373
+ break
374
+ }
375
+ case 'csv':
376
+ default: {
377
+ importData = []
378
+ const lines = []
379
+ await async.each(langFiles, (filename, done) => {
380
+ const fileBuffer = fs.readFileSync(filename, {
381
+ encoding: null
382
+ })
383
+ const detected = jschardet.detect(fileBuffer)
384
+ let fileContent
385
+ if (iconv.encodingExists(detected.encoding)) {
386
+ fileContent = iconv.decode(fileBuffer, detected.encoding)
387
+ this.log(`Encoding detected as ${detected.encoding} ${filename}`)
388
+ } else {
389
+ fileContent = iconv.decode(fileBuffer, 'utf8')
390
+ this.log(`Encoding not detected used utf-8 ${filename}`)
391
+ }
392
+ let csvDelimiter = this.csvDelimiter
393
+ if (!csvDelimiter) {
394
+ const firstLineMatches = fileContent.match(/^[^,;\t| \n\r]+\/"{0,1}[,;\t| ]{1}/)
395
+ if (firstLineMatches && firstLineMatches.length) {
396
+ const detectedDelimiter = firstLineMatches[0].slice(-1)
397
+ if (detectedDelimiter !== this.csvDelimiter) {
398
+ this.log(`Delimiter detected as ${detectedDelimiter} in ${filename}`)
399
+ csvDelimiter = detectedDelimiter
400
+ }
401
+ }
402
+ }
403
+ if (!csvDelimiter) {
404
+ const err = new Error(`Could not detect csv delimiter ${targetLanguage.path}`)
405
+ err.number = 10014
406
+ throw err
407
+ }
408
+ const options = {
409
+ delimiter: csvDelimiter
410
+ }
411
+ csv.parse(fileContent, options, (error, output) => {
412
+ if (error) {
413
+ return done(error)
414
+ }
415
+ let hasWarnedTruncated = false
416
+ output.forEach(line => {
417
+ if (line.length < 2) {
418
+ throw new Error(`Too few columns detected: expected 2, found ${line.length} in ${filename}`)
419
+ }
420
+ if (line.length !== 2 && !hasWarnedTruncated) {
421
+ this.log(`Truncating, too many columns detected: expected 2, found extra ${line.length - 2} in ${filename}`)
422
+ hasWarnedTruncated = true
423
+ }
424
+ line.length = 2
425
+ })
426
+ lines.push(...output)
427
+ done(null)
428
+ })
429
+ }).then(() => {
430
+ lines.forEach(line => {
431
+ const [file, id, ...path] = line[0].split('/')
432
+ importData.push({
433
+ file,
434
+ id,
435
+ path: path.filter(Boolean).join('/'),
436
+ value: line[1]
437
+ })
438
+ })
439
+ }, err => {
440
+ throw new Error(`Error processing CSV files: ${err}`)
441
+ })
442
+ break
443
+ }
444
+ }
445
+
446
+ // check import validity
447
+ const item = importData[0]
448
+ const isValid = Object.prototype.hasOwnProperty.call(item, 'file') && Object.prototype.hasOwnProperty.call(item, 'id') && Object.prototype.hasOwnProperty.call(item, 'path') && Object.prototype.hasOwnProperty.call(item, 'value')
449
+ if (!isValid) {
450
+ throw new Error('Sorry, the imported File is not valid')
451
+ }
452
+
453
+ // maintain output order with original translate tasks
454
+ // TODO: could probably improve this with read order rather than file order
455
+ const typeSortLevel = {
456
+ course: 1,
457
+ contentObjects: 2,
458
+ articles: 3,
459
+ blocks: 4,
460
+ components: 5
461
+ }
462
+ importData.sort((a, b) => {
463
+ const typeSort = ((typeSortLevel[a.file] || 100) - (typeSortLevel[b.file] || 100))
464
+ return typeSort || a.id.length - b.id.length || a.id.localeCompare(b.id)
465
+ })
466
+
467
+ // update data
468
+ importData.forEach(data => {
469
+ const { file, item } = targetLanguage.getFileItemById(data.id)
470
+ const attributePath = data.path.split('/').filter(Boolean)
471
+ const currentValue = _.get(item, attributePath)
472
+ if (currentValue === data.value) {
473
+ // value is unchanged, skip
474
+ return
475
+ }
476
+ this.log(`#${data.id}\t${attributePath.join('.')}`)
477
+ this.log(` '${currentValue}'`)
478
+ this.log(` '${data.value}'`)
479
+ _.set(item, attributePath, data.value)
480
+ file.changed()
481
+ })
482
+ if (!targetLanguage.hasChanged) {
483
+ this.warn('No changed were found, target and import are identical.')
484
+ }
485
+
486
+ // save data
487
+ if (!this.isTest) {
488
+ this.data.save()
489
+ }
490
+
491
+ return this
492
+ }
493
+ }
494
+
495
+ export default Translate