Source: job-manager/sampleJobManager.js

Source: job-manager/sampleJobManager.js

/**
 * Copyright (c) 2019 Oracle and/or its affiliates. All rights reserved.
 * Licensed under the Universal Permissive License v 1.0 as shown at http://oss.oracle.com/licenses/upl.
 */
/* globals app, module, __dirname */
/* jshint esversion: 6 */
var translationProvider = require('../provider/translationProvider').factory.create(),
	persistenceStore = require('./persistenceStore').factory.create(),
	fileImporter = require('./sampleFileImporter'),
	fileTranslator = require('./sampleFileTranslator'),
	fileDownloader = require('./sampleFileDownloader'),
	localeMap = require('./localeMap');

/**
 * Manage the translation of a job within the translation connector. <br/>
 * Once the job has been created and the zip file imported, the job-manager takes over the actual translation of the zip file. <br/>
 * The steps involved are: <br/>
 * <ul>
 * <li>Import all files into the LSP:  status updated to "TRANSLATING" (% complete set at 0%)</li>
 * <li>Request files are translated into the target languages</li>
 * <li>Poll until complete: status updated to "DOWNLOADING" (% complete updated also added during polling - stops at 99%)</li>
 * <li>Download files: status updated to "TRANSLATED"  (% complete updated to 100%)</li>
 * <li>If any steps error: status is updated to "FAILED"</li>
 * </ul>
 * @constructor
 * @alias SampleJobManager
 */
var SampleJobManager = function () {
	// on startup - run through and re-enable all non-TRANSLATED jobs
	this.restartJobs();
};

/**
 * Restart any jobs that were previously running. <br/>
 * This function gets called when the translation connector starts up. Any jobs that were previously running and in a state to re-start are kicked off again. <br/>
 * This is a background tasks and the jobs will run until the status is updated to "TRANSLATED" or "FAILED"
 */
SampleJobManager.prototype.restartJobs = function () {
	var self = this;

	// get all the the existing jobs
	persistenceStore.getAllJobs().then(function (allJobConfigs) {
		// get all the jobs that need to be re-started
		allJobConfigs.filter(function (jobConfig) {
			// If the job is not finished (i.e.: TRANSLATED), we need to re-start it
			// If the job has 'FAILED' then we don't restart it, they need to re-submit the job and start again
			// In order to restart, the status must be at least "IMPORTING" (i.e.: not "CREATED") so we have the zip file to work from
			return ['TRANSLATED', 'FAILED', 'CREATED'].indexOf(jobConfig.status) === -1;
		}).map(function (jobConfig) {
			// ok, we have a running job that we need to re-start, kick it off again
			console.log('restarting...:' + jobConfig.properties.id + ' from: ' + jobConfig.status);
			self.translateJob(jobConfig);
		});
	});
};

/**
 * @typedef {Object} JobConfig
 * @memberof SampleJobManager
 * @property {string} name Name of the connector job. 
 * @property {string} workflowId Identifier of the Language Service Provider workflow to use to translate this job
 * @property {string} authToken Bearer token to use to connect to the Language Service Provider.  Since the job-manager works in the background and restarts jobs, it needs access to this token. 
 * @property {('CREATED'|'IMPORTING'|'TRANSLATING'|'DOWNLOADING'|'TRANSLATED')} status The status of the job.
 * @property {string} statusMessage Any message included with the status update. 
 * @property {number} progress Percentage progress through the translation of the job.
 * @property {object} properties Language Service Provider properties.
 * @property {string} properties.id Identifier of the job in the connector.
 * @property {string} properties.projectId Identifier of the project in the Language Service Provider.
 * @property {string} translatedZipFile Name of the zip file containing all the translations for the job.
 */

/**
 * @typedef {Object} file
 * @memberof SampleJobManager
 * @property {string} name The name of the resource to translate from the job.json file.
 * @property {string} id The reference identifier for this resource in the job.json file.
 * @property {string} path The name of the file on the file system.
 */

/**
 * @typedef {Object} translation
 * @memberof SampleJobManager.contentType.field
 * @property {boolean} translate If the field should be translated or not.
 * @property {String} note The note for the translator.
 */

/**
 * @typedef {Object} field
 * @memberof SampleJobManager.contentType
 * @property {string} name The name of the field.
 * @property {SampleJobManager.contentType.field.translation} The translation object of the field.
 */

/**
 * @typedef {Object} contentType
 * @memberof SampleJobManager
 * @property {string} contentType.name The name of the content type.
 * @property {SampleJobManager.contentType.field[]} contentType.fields The fields of the content type.
 */

/**
 * @typedef {Object} JobDetails
 * @memberof SampleJobManager
 * @property {object} assets Assets resources to translate
 * @property {object} assets.jobJSON The job.json object for assets.
 * @property {SampleJobManager.file[]} assets.files The array of files that need to be translated
 * @property {SampleJobManager.contentType[]} assets.contentTypes The array of content types.
 * @property {object} site Site resources to translate
 * @property {object} site.jobJSON The job.json object for site resources.
 * @property {SampleJobManager.file[]} site.files The array of files that need to be translated
 * @property {string} sourceLanguage The source language for the job.
 * @property {String[]} targetLanguages Array of target languages the resources will be translated into.
 */


/**
 * Translate a job whose zip file has been imported into the persistence store. <br/>
 * This first checks the current state of the job and executes any further steps required. 
 * <ul>
 * <li>Import all files into the LSP:  status updated to "TRANSLATING" (% complete set at 0%)</li>
 * <li>Request files are translated into the target languages</li>
 * <li>Poll until complete: status updated to "DOWNLOADING" (% complete updated also added during polling - stops at 99%)</li>
 * <li>Download files: status updated to "TRANSLATED"  (% complete updated to 100%)</li>
 * <li>If any steps error: status is updated to "FAILED"</li>
 * </ul>
 * @param {SampleJobManager.JobConfig} jobConfig - The configuration of the connector job to run. This information is held as metadata in the connector for the job. 
 */
SampleJobManager.prototype.translateJob = function (jobConfig) {
	var self = this;

	// get the job details
	self.getJobDetails(jobConfig).then(function (jobDetails) {
		var jobSteps = [];

		// get all the supported target languages
		var targetLanguages = jobDetails.targetLanguages.filter(function (language) {
			return language !== null && language !== jobDetails.sourceLanguage;
		});

		// if job is not translated or failed
		if (['FAILED', 'TRANSLATED'].indexOf(jobConfig.status) === -1) {
			if (jobDetails.sourceLanguage === null) {
				// source language is not supported by the connector
				console.log('sampleJobManager.translateJob(): source locale: "' + jobDetails.origSourceLanguage + '" is not supported by the connector.');

				// update status to failed
				jobConfig.status = 'FAILED';
				jobSteps.push(function () {
					return self.updateStatus(jobConfig, 'FAILED');
				});
			} else if (targetLanguages.length === 0) {
				// nothing to do, either no supported locales or only equivalent target locales ('es' ==> 'es-ES')
				// update status to DOWNLOADING to skip IMPORTING/TRANSLATING steps below
				jobConfig.status = 'DOWNLOADING';
			}
		}

		// allow re-starting of a job.  
		// Only execute steps not already executed based on the job status
		switch (['CREATED', 'IMPORTING', 'TRANSLATING', 'DOWNLOADING', 'TRANSLATED', 'FAILED'].indexOf(jobConfig.status)) {
			case 0: // CREATED
				console.log('sampleJobManager.translateJob(): unable to start job - ' + jobConfig.name + ' zip file not available');
				return;
			case 1: // IMPORTING
				// import all the files into LSP server
				jobSteps.push(function () {
					return fileImporter.importFiles(jobConfig, jobDetails);
				});
				// update status to TRANSLATING
				jobSteps.push(function () {
					return self.updateStatus(jobConfig, "TRANSLATING");
				});
			case 2: // TRANSLATING
				// tell the LSP provider to translate the files into the requested locales
				jobSteps.push(function () {
					return fileTranslator.translateFiles(jobConfig, jobDetails);
				});
				// wait until the LSP provider project is 100% complete
				jobSteps.push(function () {
					return self.pollStatus(jobConfig, jobDetails);
				});
				// update status to DOWNLOADING
				jobSteps.push(function () {
					return self.updateStatus(jobConfig, "DOWNLOADING");
				});
			case 3: // DOWNLOADING
				// download the translated files
				jobSteps.push(function () {
					return fileDownloader.downloadFiles(jobConfig, jobDetails);
				});
				// now create the zip for the translated files
				jobSteps.push(function () {
					return self.createZip(jobConfig, jobDetails);
				});
				// update progress to 100%
				jobSteps.push(function () {
					return self.updateProgress(jobConfig, "100");
				});
				// update status to TRANSLATED
				jobSteps.push(function () {
					return self.updateStatus(jobConfig, "TRANSLATED");
				});
			case 4: // TRANSLATED
				// already translated, we're done
				break;
			case 5: // FAILED
				// job failed, nothing further we can do
				break;
			default:
				// unable to restart this job
				console.log('sampleJobManager.translateJob(): unable to restart job - ' + jobConfig.name + ' from status - ' + jobConfig.status);
				return;
		}

		// now run through all the steps identified
		// chain the promises in the array so that they execute as: return p1.then(return p2.then(return p3.then(...)));
		var doTranslateJob = jobSteps.reduce(function (previousPromise, nextPromise) {
				return previousPromise.then(function () {
					// wait for the previous promise to complete and then return a new promise for the next 
					return nextPromise();
				});
			},
			// Start with a previousPromise value that is a resolved promise 
			Promise.resolve());

		// once all steps have completed, job is translated
		doTranslateJob.then(function () {
			console.log('sampleJobManager.translateJob(): Completed translation of job - ' + jobConfig.name);
		}).catch(function (e) {
			console.log('SampleJobManager.translateJob(): failed to translate job - ' + jobConfig.name);
			var cyclicEntry = [];
			console.log(JSON.stringify(e, function (key, val) {
				if (val !== null && typeof val === "object") {
					if (cyclicEntry.indexOf(val) >= 0) {
						return;
					}
					cyclicEntry.push(val);
				}
				return val;
			}));

			// update the job status to 'FAILED' so we don't try and re-do this job, they need to re-submit it
			self.updateStatus(jobConfig, "FAILED", e);
		});
	}).catch(function (error) {
		console.log('sampleJobManager.translateJob(): failed to get job details for - ' + jobConfig.name);
		return;
	});
};

/**
 * Get the job details from the job.json file included in the source zip file<br/>
 * <br/>
 * Within the job.json file, contentTypes node provides metadata of each content type and
 * contentItems node lists the assets.<br/>
 * <pre>
 *     "contentTypes": [
 *         {
 *             "name": {content type name}
 *             "fields": [
 *                 "{field name}": {
 *                     "caas-translation": {
 *                         "translate": true | false // whether the field should be translated or not
 *                     }
 *                 },...
 *             ],
 *             "systemFields": [
 *                 "file": {
 *                     "caas-translation": {
 *                         "translate": true | false // whether the native file of the asset should be translated or not
 *                     }
 *                 },...
 *             ]
 *         },...
 *     ],
 *     "contentItems": [
 *         {
 *             "name": {asset name},
 *             "id": {GUID},
 *             "type": {content type name}
 *         },...
 *     ]
 * </pre>
 * The folder structure differs between job types - the job types are: <br/>
 * An "assets" job type: 
 * <ul>
 *   <li>job.json</li>
 *   <li>root</li>
 *   <ul>
 *     <li>[GUID-{name}.json,...]</li>
 *     <li>[files]
 *       <ul>
 *         <li>[GUID,...]
 *           <ul>
 *             <li>[{binary-file}]</li>
 *           </ul>
 *         </li>
 *       </ul>
 *     </li>
 *   </ul>
 * </ul>
 * Or a "site" job type: 
 * <ul>
 *   <li> assets
 *     <ul>
 *       <li>job.json</li>
 *       <li>root</li>
 *       <ul>
 *         <li>[GUID-{name}.json,...]</li>
 *         <li>[files]
 *           <ul>
 *             <li>[GUID,...]
 *               <ul>
 *                 <li>[{binary-file}]</li>
 *               </ul>
 *             </li>
 *           </ul>
 *         </li>
 *       </ul>
 *     </ul>
 *   </li>
 *   <li> site
 *     <ul>
 *       <li>job.json</li>
 *       <li>root</li>
 *     </ul>
 *   </li>
 * </ul>
 * @param {SampleJobManager.JobConfig} jobConfig - The configuration of the connector job to run. This information is held as metadata in the connector for the job. 
 * @returns {Promise.<SampleJobManager.JobDetails>} A promise that contains the details of the combined job.json files
 */
SampleJobManager.prototype.getJobDetails = function (jobConfig) {
	return new Promise(function (resolve, reject) {
		var jobId = jobConfig.properties.id;
		persistenceStore.getSourceJobJSON({
			jobId: jobId
		}).then(function (jobFiles) {
			var jobDetails = {
					type: jobFiles.type
				},
				folders = [],
				assetJobJSON,
				siteJobJSON;

			// include the asset files
			if (jobFiles.assetsJSON) {
				try {
					assetJobJSON = JSON.parse(jobFiles.assetsJSON);
					jobDetails.assets = {
						jobJSON: assetJobJSON,
						files: [],
						binaryFiles: [],
						contentTypes: []
					};

					// Content type metadata
					for (const jobContentType of (assetJobJSON.contentTypes || [])) {
						var contentType = {
								name: jobContentType.name,
								fields: [],
								systemFields: []
							};

						// fields property is introduced in 20.1.3.
						if (jobContentType.hasOwnProperty('fields')) {
							Object.keys(jobContentType.fields).forEach(function(key) {

								var jobField = jobContentType.fields[key];

								// If 'caas-translation' property exists, then create field object.
								if (jobField.hasOwnProperty('caas-translation')) {

									var field = {
											name: jobField.name,
											translation: {
												translate: jobField['caas-translation'].translate,
												note: jobField['caas-translation'].note
											}
										};

									contentType.fields.push(field);
								}
							});

							if (contentType.fields.length > 0) {
								jobDetails.assets.contentTypes.push(contentType);
							}
						}

						// systemFields property is introduced in 22.1.1.
						if (jobContentType.hasOwnProperty('systemFields')) {
							// Look for file. Other system fields will be consumed in the future.
							if (jobContentType.systemFields.file) {
								var systemField = {
										name: jobContentType.systemFields.file.name,
										translation: {
											translate: jobContentType.systemFields.file['caas-translation'].translate,
											note: jobContentType.systemFields.file['caas-translation'].note
										}
									};

								contentType.systemFields.push(systemField);
							}
						}
					}

					// list all the asset files (including the file name)
					for (const contentItem of (assetJobJSON.contentItems || [])) {

						jobDetails.assets.files.push({
							name: contentItem.name,
							id: contentItem.id,
							path: contentItem.id + '-' + contentItem.name + '.json'
						});

						// Check if the content item is of a type with translatable binary file.

						// To be safe, check that the type field, which is new, exists first.
						if (contentItem.type) {
							// Look up the type object
							var contentItemType = jobDetails.assets.contentTypes.find(function(contentType) {
								return contentType.name === contentItem.type;
							});

							if (contentItemType) {
								// Look up the file object in system field
								var contentItemTypeFile = contentItemType.systemFields.find(function(field) {
									return field.name === 'file';
								});

								if (contentItemTypeFile && contentItemTypeFile.translation.translate) {
									// Each item represents a binary files folder to be discovered.
									folders.push({
										id: contentItem.id,
										folderPath: persistenceStore.getBinaryFilePath({ contentItemId: contentItem.id})
									});
								}
							}
						}
					}

					// Discovery of binary files of the digital asset in the list of folders.
					// There should be one binary file per folder, but pave the way for having multiple files now.
					// List all binary files in the files/<content-id> folders.
					var allBinaryFiles = [];

					for (const folder of (folders || [])) {
						var files = persistenceStore.getFolderFiles({
								jobId: jobConfig.properties.id,
								jobType: jobConfig.jobType,
								folderPath: folder.folderPath,
								fileType: 'assets',
								id: folder.id
							});

						allBinaryFiles = allBinaryFiles.concat(files);
					}

					jobDetails.assets.binaryFiles = jobDetails.assets.binaryFiles.concat(allBinaryFiles);
					
				} catch (e) {
					console.log('sampleJobManager.getJobDetails(): failed to parse asset job.json file for - ' + jobId);
					console.log(e);
					return reject(e);
				}
			}

			// include the site files
			if (jobFiles.siteJSON) {
				persistenceStore.getSourceFile({
					jobId: jobId,
					fileType: 'site',
					filePath: 'structure.json'
				}).then(function (siteStructureFile) {
					try {
						var siteStructure = JSON.parse(siteStructureFile);

						siteJobJSON = JSON.parse(jobFiles.siteJSON);
						jobDetails.site = {
							jobJSON: siteJobJSON,
							files: []
						};

						// add in the pages
						for (const pageItem of (siteJobJSON.pages || [])) {
							var pageId = pageItem.name.replace('.json', ''),
								pageEntry;

							// find the entry for this page so we use the menu name rather than id for display
							pageEntry = (siteStructure.pages || []).find(function (page) {
								return page.id.toString() === pageId;
							});
							if (!pageEntry) {
								pageEntry = {
									"id": pageId,
									"name": pageItem.name
								};
							}

							jobDetails.site.files.push({
								name: 'Page:' + pageEntry.name,
								id: pageItem.id,
								path: pageItem.name
							});
						}

						// add in the structure/siteinfo
						jobDetails.site.files.push({
							name: 'Site Menu',
							id: 'structure',
							path: 'structure.json'
						});
						jobDetails.site.files.push({
							name: 'Site Information',
							id: 'siteinfo',
							path: 'siteinfo.json'
						});

					} catch (e) {
						console.log('sampleJobManager.getJobDetails(): failed to parse asset job.json file for - ' + jobId);
						return reject(e);
					}

					// get the source & target languages for site and assets (will be the same)
					jobDetails.origSourceLanguage = siteJobJSON.sourceLanguage;
					jobDetails.origTargetLanguages = siteJobJSON.targetLanguages;

					// update locales to those supported by the LSP
					jobDetails.sourceLanguage = localeMap.getLSPLocale(siteJobJSON.sourceLanguage);
					jobDetails.targetLanguages = siteJobJSON.targetLanguages.map(function (targetLanguage) {
						return localeMap.getLSPLocale(targetLanguage);
					});

					// remove any duplicates from the target languages array
					// e.g.: 
					//   ['de-DE', 'de-DE-x-bayern'] would map to: ['de-DE', 'de-DE']
					//   ['en', 'en-US'] would map to: ['en-US', 'en-US']
					jobDetails.targetLanguages = [...new Set(jobDetails.targetLanguages)];

					return resolve(jobDetails);
				});
			} else {
				// get the source & target languages for assets only
				jobDetails.origSourceLanguage = assetJobJSON.sourceLanguage;
				jobDetails.origTargetLanguages = assetJobJSON.targetLanguages;

				// update locales to those supported by the LSP
				jobDetails.sourceLanguage = localeMap.getLSPLocale(assetJobJSON.sourceLanguage);
				jobDetails.targetLanguages = assetJobJSON.targetLanguages.map(function (targetLanguage) {
					return localeMap.getLSPLocale(targetLanguage);
				});

				// remove any duplicates from the target languages array
				// e.g.: 
				//   ['de-DE', 'de-DE-x-bayern'] would map to: ['de-DE', 'de-DE']
				//   ['en', 'en-US'] would map to: ['en-US', 'en-US']
				jobDetails.targetLanguages = [...new Set(jobDetails.targetLanguages)];

				return resolve(jobDetails);
			}
		}).catch(function (error) {
			console.log('sampleJobManager.getJobDetails(): unable to get job.json files for job - ' + jobId);
			return reject(error);
		});
	});
};

/**
 * Update the job status in the job metadata.
 * @param {SampleJobManager.JobConfig} jobConfig - The configuration of the connector job to run. This information is held as metadata in the connector for the job. 
 * @property {('CREATED'|'IMPORTING'|'TRANSLATING'|'DOWNLOADING'|'TRANSLATED')} status The new status of the job.
 * @property {string} statusMessage Any message to include with the status update. 
 * @returns {Promise.<SampleJobManager.JobConfig>} A promise that contains the details of the updated job metadata
 */
SampleJobManager.prototype.updateStatus = function (jobConfig, status, statusMessage) {
	// get the current job metadata
	return persistenceStore.getJob({
		jobId: jobConfig.properties.id
	}).then(function (jobMetadata) {
		// update the status value in the job metadata
		jobMetadata.status = status;
		jobMetadata.statusMessage = statusMessage;
		return persistenceStore.updateJob(jobMetadata);
	});
};

/**
 * Update the job progress in the job metadata.
 * @param {SampleJobManager.JobConfig} jobConfig - The configuration of the connector job to run. This information is held as metadata in the connector for the job. 
 * @property {number} progress New percentage progress value. 
 * @returns {Promise.<SampleJobManager.JobConfig>} A promise that contains the details of the updated job metadata
 */
SampleJobManager.prototype.updateProgress = function (jobConfig, progress) {
	return persistenceStore.getJob({
		jobId: jobConfig.properties.id
	}).then(function (jobMetadata) {
		// update the progress
		jobMetadata.progress = jobConfig.progress = progress;

		return persistenceStore.updateJob(jobMetadata);
	});
};

/**
 * Background task to poll the Language Service Provider and wait for the job to be 100% complete.
 * @param {SampleJobManager.JobConfig} jobConfig - The configuration of the connector job to run. This information is held as metadata in the connector for the job. 
 * @param {SampleJobManager.JobDetails} jobDetails - The details of the combined job.json files.
 * @returns {Promise} A promise that resolves when polling gets to 100%.
 */
SampleJobManager.prototype.pollStatus = function (jobConfig, jobDetails) {
	var self = this;

	return new Promise(function (resolve, reject) {

		pollServer = function () {
			// get the current status for the job
			translationProvider.getProjectStatus(jobConfig.authToken, jobConfig.properties.projectId).then(function (projectStatus) {
				// if projectStatus complete...
				if (projectStatus.properties.progress.toString() === "100") {
					// update status to "DOWNLOADING"
					self.updateStatus(jobConfig, "DOWNLOADING", "Downloading translated files").then(function () {
						self.updateProgress(jobConfig, "95").then(function (jobMetadata) {
							// resolve 
							resolve();
						}).catch(function (error) {
							console.log('sampleJobManager.pollStatus(): failed to update progress for job - ' + jobConfig.properties.id);
							reject(error);
						});
					}).catch(function (error) {
						console.log('sampleJobManager.pollStatus(): failed to update status for job - ' + jobConfig.properties.id);
						reject(error);
					});
				} else {
					// Show the actual progress returned by the LSP.
					progress = projectStatus.properties.progress;
					self.updateProgress(jobConfig, progress).then(function (jobMetadata) {
						// wait and poll again
						setTimeout(function () {
							pollServer();
						}, 5000);
					});
				}
			}).catch(function (e) {
				// failed to get project status - this may be fatal, would need to confirm project exists
				// update status to "FAILED"
				self.updateStatus(jobConfig, "FAILED", "Failed to get status for project - " + jobConfig.properties.projectId);
				reject();
			});
		};

		// kick off the polling for this job
		pollServer();
	});
};

/**
 * Create a zip file of all the downloaded translations.
 * @param {SampleJobManager.JobConfig} jobConfig - The configuration of the connector job to run. This information is held as metadata in the connector for the job. 
 * @param {SampleJobManager.JobDetails} jobDetails - The details of the combined job.json files.
 * @returns {Promise.<SampleJobManager.JobConfig>} A promise that contains the job metadata updated with the location of the translation zip file.
 */
SampleJobManager.prototype.createZip = function (jobConfig, jobDetails) {
	return persistenceStore.createTranslationZip({
		jobId: jobConfig.properties.id
	});
};


// Export the mock translator
module.exports = new SampleJobManager();