Home Reference Source Repository

js/base/services/DataService.js

'use strict';
import {convertToString} from '../Utilities';
import bases from 'bases';
/*
 * base/js/services/DataService.js
 * Create factory for the DataService
 */
var DataService = ($q, $http, $rootScope, LoggerService, SynthError, UserSession, SynthConfig, Lock) => {

	var LOG = LoggerService('DataService');
	// Characters that will be used to generate filenames
	// Windows Quirks
	// - case insensitive, hense no capitals
	// - underscores are not allowed
	// No other special characters are used because to mess with sorting
	const FILENAME_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz';

	// The lock key that is used when generating new file names
	const FILE_CREATE_LOCK = 'DataService.FILE_CREATE_LOCK';


	/**
	 * Constructor
	 */
	class DataServiceImpl{

		constructor(){
			this.cachedRouteFileSystem = null;
		}

		/**
		 * TODO remove this, device will be ready before angular starts
		 */
		cordovaReady(){
			return $q.when();
		}

		/**
		 * Gets the directory in which content data is stored
		 * /Synthesis/content/
		 */
		getContentDirectory (){
			return this.checkAndCreateDirectory('content');
		}

		/**
		 * /Synthesis/data/
		 */
		getDataDirectory(){
			return this.checkAndCreateDirectory('data');
		}

		/**
		 * Gets a file in the data directory
		 */
		getDataFile(filename){
			return this.getFileData('data', filename);
		}

		/**
		 * Merge json to a file in the /Synthesis/data directory
		 * @param filename Name of the file inside of /Synthesis/data/
		 * @param jsonObject The object to merge into this file
		*/
		mergeDataFile(filename, jsonObject){
			return this.mergeToFile('data', filename, jsonObject);
		}

		/**
		 * Creates a new file in the data directory of the app
		 * This file will be created in an abitrary directory under /Synthesis/data/{generatedFolder}/{filename}
		 * @param filename Name of the file to create. E.g. MyImage.png
		 * @returns A promise resolving to the FileEntry representing the file
		 */
		createNewFile(filename){
			var dataDirEntry;
			const self = this;
			function getLock(){
				return Lock.getLock(FILE_CREATE_LOCK);
			}

			function getContentDirectory(){
				return self.getContentDirectory();
			}

			/**
			 * Gets the listing of all files within the DirectoryEntry
			 * @param dirEntry Directory entry to get the contents listing of
			 * @returns A promise that resolves to an array of entries.
			 */
			function getDirectoryListing(dirEntry){
				dataDirEntry = dirEntry;
				var listingDefered = $q.defer();
				var dirReader = dataDirEntry.createReader();
				var entries = [];

				// Call the reader.readEntries() until no more results are returned.
				// TODO uptomise and only get the last directory name
				var readEntries = function() {
					dirReader.readEntries((results) =>{
						// If no more entries are found, we are done
						if (!results.length) {
							listingDefered.resolve(entries.sort((fileEntry1, fileEntry2) => {
								var name1 = fileEntry1.name, name2 = fileEntry2.name;
								if(name1.length !== name2.length){
									return name1.length - name2.length;
								}
								return name1.localeCompare(name2);
							}));
						}
						else{
							entries = entries.concat(Array.prototype.slice.call(results || [], 0));
							readEntries();
						}
					}, (error) =>{
						LOG.warn('Failed to get directory listing, code:' + error.code);
						listingDefered.reject(SynthError(1004, error.code));
					});
				};
				readEntries(); // Start reading dirs.
				return listingDefered.promise;
			}

			/**
			 * Finds the next available directory based on the array of files within the
			 * file listing
			 * @param fileListing Array of files in the contants directory
			 * @returns A name of the next available filename that can be used.
			 */
			function findNextOpenDirectory(fileListing){
				// If there is no files yet, we start with 1.ext
				if(fileListing.length === 0){
					return $q.when(FILENAME_CHARS.charAt(0));
				}
				// Else we take the last file in the list, and get the next available name
				else {
					// Get the file name
					var lastDirectoryName = fileListing[fileListing.length - 1].name;
					var index = bases.fromAlphabet(lastDirectoryName, FILENAME_CHARS);
					var newDirectoryName = bases.toAlphabet(index + 1, FILENAME_CHARS);
					return $q.when(newDirectoryName);
				}
			}

			/**
			 * Creates the new directory
			 * @param directoryName Name of the directory to create
			 * @returns A promise that resolves with the DirectoryEntry
			 */
			function createNewDirectory(directoryName){
				var deferred = $q.defer();
				dataDirEntry.getDirectory(directoryName, {create : true, exclusive : true}, (dirEntry) => {
					deferred.resolve(dirEntry);
				},
				// Error Creating directory
				(error) => {
					LOG.warn(`Error creating path ${directoryName} : ${error}`);
					deferred.reject(SynthError(1004, error));
				});
				return deferred.promise;
			}

			function createFile(directoryEntry){
				var deferred = $q.defer();
				directoryEntry.getFile(filename, {create : true},
					(fileEntry) => {
						LOG.debug('Created new file: ' + fileEntry.toInternalURL());
						deferred.resolve(fileEntry);
					},
					() => {
						LOG.warn('Failed to create file');
						deferred.reject(SynthError(1004));
					}
				);
				return deferred.promise;
			}

			return getLock()
				.then(getContentDirectory)
				.then(getDirectoryListing)
				.then(findNextOpenDirectory)
				.then(createNewDirectory)
				.then(createFile)
				.then((returnData) => {
					return Lock.returnLock(FILE_CREATE_LOCK, returnData);
				}, (reason) =>{
					return Lock.returnLock(FILE_CREATE_LOCK, reason, false);
				});
		}


		/**
		 * Gets the root directory where the application is writing files
		 */
		getApplicationRoot(){
			const deferred = $q.defer();
			// TODO remove the false!
			// if (false && this.cachedRouteFileSystem !== null){
			//deferred.resolve(this.cachedRouteFileSystem);
			//}
			//else {
			var self = this;
			this.cordovaReady().then(() => {
				window.requestFileSystem(LocalFileSystem.PERSISTENT, 0, gotFS, fail);
				function gotFS(fileSystem) {
					fileSystem.root.getDirectory(SynthConfig.dataDir, {create : true, exclusive : false},
						(directory) => {
							self.cachedRouteFileSystem = directory;
							deferred.resolve(directory);
						}, (error) => {
							LOG.warn('Failed to get application root : ' + error);
							deferred.reject(SynthError(1004, error.code));
						}
					);
				}
				function fail(error) {
					LOG.warn('Failed to get filesystem, code:' + error.code);
					deferred.reject(SynthError(1004, error.code));
				}
			});
			//}
			return deferred.promise;
		}

		/**
		 * Checks if the specified path exists, and creates all parent directories
		 * if required.
		 * The path is created from the root of the application path
		 */
		checkAndCreateDirectory(path){
			const deferred = $q.defer();
			this.getApplicationRoot().then(
				function(root){

					// Throw out './' or '/' and move on to prevent something like '/foo/.//bar'.
					function removeBlanks(folders){
						if (folders.length > 0 && (folders[0] === '.' || folders[0] === '')) {
							folders = folders.slice(1);
							return removeBlanks(folders); // Call again to check the next one too
						}
						else{
							return folders;
						}
					}

					function createDir (rootDirEntry, folders){
						folders = removeBlanks(folders);

						// If there are no more folders left, this is it
						if (folders.length === 0){
							deferred.resolve(rootDirEntry);
							return;
						}

						var folder = folders[0];
						let foldersLeft = folders.slice(1);
						//LOG.debug('Creating directory : ' + folder + ' in parent: ' + rootDirEntry.fullPath);
						rootDirEntry.getDirectory(folder, {create : true, exclusive : false}, (dirEntry) => {
							// Recursively add the new subfolder (if we still have another to create).
							if (foldersLeft.length > 0) {
								createDir(dirEntry, foldersLeft);
							}
							else{
								deferred.resolve(dirEntry);
							}
						},
						// Error Creating directory
						(error) => {
							LOG.warn(`Error creating path ${path} : ${error}`);
							deferred.reject(SynthError(1004, error));
						});
					}
					var rootPath = `cdvfile://localhost/persistent/${SynthConfig.dataDir}`;
					if(path.indexOf(rootPath) === 0){
						path = path.substring(rootPath.length);
					}

					createDir(root, path ? path.split('/') : []);
				});
			return deferred.promise;
		}

		/**
		 * Gets the full path of the root directory.
		 * The success of the promise will return a string of the full path
		 * to the application root directory.
		 */
		getLocalFilePath(){
			const deferred = $q.defer();
			this.getApplicationRoot().then(
				// Success
				(root) => {
					deferred.resolve(root.fullPath);
				},
				// Failed
				(error) => {
					deferred.reject(SynthError(1004, error));
				}
			);
			return deferred.promise;
		}

		/**
		 * Writes to the data file of a tool.
		 * NOTE: This function will replace the current data file of the tool
		 *
		 * @param moduleId - Id of the module this tool is in.
		 * @param toolname - Name of the tool to write data to.
		 * @param jsonData - Either a JSON Object, or a JSON string that represents the data
		 * that should be written to the tool's data file
		 * @param isUploadData - Flag if the data is for the upload file of the tool (optional, default=false)
		 */
		writeToolData(moduleId, toolname, jsonData, isUploadData){
			const deferred = $q.defer();
			this.getDataDirectory().then(
				// Success
				function(root){
					var filename = moduleId + '-' + toolname + (isUploadData ? '.up.json' : '.json');
					root.getFile(filename, {create : true, exclusive : false},
						(fileEntry) => {
							fileEntry.createWriter(onWriterReady, fail);
						}, fail);

					function onWriterReady(writer) {
						writer.onwriteend = function() {
							deferred.resolve();
						};

						var dataString = convertToString(jsonData);
						if (dataString != null){
							writer.write(dataString);
						}
						else{
							LOG.warn('Failed while trying to write tool data, Invalid data type given to write as tool data');
							deferred.reject(SynthError(1000, 'Invalid data type given to write as tool data'));
						}
					}
					/**
					 * Function for when an IO action fails
					 */
					function fail(error) {
						LOG.warn('Failed while trying to write tool data, error : ' + error);
						deferred.reject(SynthError(1004));
					}
				},
				// Fail
				function(){
					deferred.reject();
				}
			);
			return deferred.promise;
		}

		/**
		 * Write content to a file
		 *
		 * @param directoryPath - Path of the directory to write in.
		 * @param filename - Name of the file to write in.
		 * @param data - Data to write to the file. The data can be a string or a JSON object
		 * which will be converted to a json string.
		 */
		writeToFile(directoryPath, filename, data){
			const self = this;

			function writeFile(directory){
				var writeDeferred = $q.defer();
				directory.getFile(filename, {create : true, exclusive : false},
					(fileEntry) => {
						fileEntry.createWriter(onWriterReady, fail);
					}, fail);

				// Writer is ready to write
				function onWriterReady(writer) {
					writer.onwriteend = function() {
						writeDeferred.resolve(data);
						self.busyWriting = false;
					};
					var outputData = convertToString(data);
					writer.write(outputData);
				}
				// Function for when an IO action fails
				function fail(error) {
					LOG.warn('Failed while trying to write to file, error: ' + error);
					writeDeferred.reject(SynthError(1004));
				}
				return writeDeferred.promise;
			}


			return this.checkAndCreateDirectory(directoryPath).then(
				// Success getting directory path
				function(directory){
					// First get a write lock on the file
					return Lock.getLock(`${directoryPath}${filename}`)
					// Now write to the file
						.then(() => {
							return writeFile(directory);
						})
					// Return our lock on the file
						.then((writtenData) => {
							return Lock.returnLock(`${directoryPath}${filename}`, writtenData);
						}, (reason) => {
							return Lock.returnLock(`${directoryPath}${filename}`, reason, false);
						});
				},
				// Failed to get directory path
				function(error){
					LOG.warn('Failed while trying to write to file, error: ' + error);
					return $q.reject(SynthError(1004));
				});
		}

		/**
		 * Copies content from the web to a file
		 */
		copyFromWebToFile(webPath, directoryPath, filename){
			const deferred = $q.defer(),
				self = this;
			this.getWebData(webPath).then(
				// Success
				(data) => {
					copyDataToFile(data);
				},
				// Failed
				(error) => {
					LOG.warn('Failed to get web data : ' + error);
					deferred.reject(SynthError(1004));
				});
			// Function to copy the web data to the file
			function copyDataToFile(data){
				self.writeToFile(directoryPath, filename, data).then(
					// Success
					() => {
						deferred.resolve();
					},
					// Failed
					(error) => {
						LOG.warn('Failed while trying to copy file from web, error: ' + error);
						deferred.reject(SynthError(1004));
					}
				);
			}
			return deferred.promise;
		}

		/**
		 * Merge content to a file.
		 * The newly merged data object is returned
		 */
		mergeToFile(directoryPath, filename, jsonObject){
			const deferred = $q.defer(),
				self = this;
			// Get the original contents of the file
			this.getFileData(directoryPath, filename).then(
				// Success
				(data) => {
					// Merge the new data to the old data
					let writeData = angular.merge(data || {}, jsonObject);


					// Write the merged data to the file
					self.writeToFile(directoryPath, filename, JSON.stringify(writeData)).then(
						// Success
						() => {
							deferred.resolve(writeData);
						},
						//Fail
						(error) => {
							deferred.reject(error);
						}
					);
				},
				//Fail
				function (error){
					deferred.reject(error);
				}
			);
			return deferred.promise;
		}

		/**
		 * Get data from a web json source
		 */
		getWebData(webPath){
			return $http({ 'method' : 'GET', 'url' : webPath})
				.then((response) => {
					return response.data;
				}, () => {
					return $q.reject(SynthError(1004));
				});
		}

		/**
		 * Gets the JSON object from a file
		 */
		getFileData(directoryPath, filename){
			LOG.debug('Getting data for: directroy=' + directoryPath + ', filename=' + filename);
			const deferred = $q.defer(),
				self = this;
			this.checkAndCreateDirectory(directoryPath).then(
				// Success
				(directory) => {
					directory.getFile(filename, {create : true},
						(fileEntry) => {
							self.getFileAsObject(fileEntry).then(
								// Success
								(object) => {
									if(LOG.isDEBUG()){
										LOG.debug('Got data :' + JSON.stringify(object));
									}
									deferred.resolve(object);
								},
								// Fail
								(error) => {
									deferred.reject(error);
								}
							);
						},
						() => {
							LOG.warn('Fail while trying to get file in directory');
							deferred.reject(SynthError(1004));
						}
					);

				},
				// Failed
				(error) => {
					deferred.reject(error);
				});
			return deferred.promise;
		}

		/**
		 * Make sure the files exists that prevent the device from scanning for library
		 * content in the Application directory
		 */
		ensureNoMediaScanFiles(){
			return this.writeToFile('', '.nomedia');
		}


		/**
		 * Gets the size of the upload data
		 */
		getToolUploadDataSize(moduleId, toolname){
			const deferred = $q.defer();
			this.getDataDirectory().then(
				// Success
				(toolRoot) => {
					const filename = `${moduleId}-${toolname}.up.json`;
					/*
					 * TODO do we have to create the file ?!
					 * This will cause each tool to have an upload file
					 * even will it will always be empty, the file will get
					 * created when checking for sync
					 */
					toolRoot.getFile(filename, {create : true},
						(fileEntry) => {
							fileEntry.file(
								(file) => {
									deferred.resolve(file.size);
								}, fail);

						}, fail
					);
				}, fail
			);

			function fail(error){
				deferred.reject(error);
			}

			return deferred.promise;
		}

		/**
		 * Delete the upload data file for a tool
		 */
		deleteToolUploadData(moduleId, toolname){
			const deferred = $q.defer();
			this.getToolDataFile(moduleId, toolname, true).then(
				// Success
				(fileEntry) => {
					fileEntry.remove(
						() => {
							deferred.resolve();
						},
						() => {
							LOG.warn('Error deleting upload file');
							deferred.reject(SynthError(1004, 'Error deleting upload file'));
						});
				},
				//Fail
				(error) => {
					deferred.reject(error);
				}
			);
			return deferred.promise;
		}

		/**
		 * Deletes a file in the contents directory
		 */
		deleteCDVFile(cdvFilePath){
			const deferred = $q.defer();
			LOG.warn('Going to delete file: ' + cdvFilePath);
			window.resolveLocalFileSystemURL(cdvFilePath,
				// Got the local file
				function(fileEntry){
					fileEntry.remove(() => {
						deferred.resolve();
					}, (error) => {
						LOG.warn('Failed to delete file : ' + cdvFilePath);
						deferred.reject(error);
					});
				},
				// Failed to get the file
				() => {
					LOG.warn('Error getting file to delete: ' + cdvFilePath + ', assuming file is already deleted.');
					deferred.resolve();
				});
			return deferred.promise;
		}

		/**
		 * Deletes an array of files
		  @param deleteFiles Array of cdv:// file protocal file path
		 */
		deleteCDVFiles(filePaths = []){
			let promise = $q.when();
			const self = this;
			angular.forEach(filePaths, function(filePath){
				promise = promise.then(function(){
					return self.deleteToolFile(filePath);
				});
			});
			return promise;
		}

		/**
		 * Gets the file entry for a tool's data file
		 * @param moduleId - ID of the module for the tool
		 * @param toolname - Name of the tool to get data file of
		 * @param isUploadData - Flag if the data file should be the upload file
		 */
		getToolDataFile(moduleId, toolname, isUploadData){
			const deferred = $q.defer();
			this.getDataDirectory().then(
				// Success
				(toolRoot) => {
					var filename = moduleId + '-' + toolname + (isUploadData ? '.up.json' : '.json');
					toolRoot.getFile(filename, {create : true},
						(fileEntry) => {
							deferred.resolve(fileEntry);
						},
						(error) => {
							LOG.warn('Error getting tool data file, error: ' + error);
							deferred.reject(SynthError(1004));
						}
					);
				},
				//Fail
				(error) => {
					deferred.reject(error);
				}
			);
			return deferred.promise;
		}

		getFileContentCDV(cdvFilePath){
			const deferred = $q.defer();

			window.resolveLocalFileSystemURL(cdvFilePath,
				// Got the local file
				function(fileEntry){
					fileEntry.file((file) => {
						var reader = new FileReader();
						reader.onloadend = function (evt) {
							deferred.resolve(evt.target.result);
						};
						reader.readAsText(file);
					}, () => {
						deferred.reject(SynthError(1004));
					});
				},
				// Failed to get the file
				() => {
					deferred.reject(SynthError(1004));
				});
			return deferred.promise;
		}

		/**
		 * Get the contents of a file without any special conversions
		 */
		getFileContent(toolId, moduleId, filePath){
			const self = this;
			var dataDir = null;
			// Returns a promise to get the tools data dir
			function getToolDataDirPromise(){
				return self.getDataDirectory(moduleId, toolId)
					.then((dir) => {
						dataDir = dir;
					});
			}

			// Returns a promise to get the content of the file
			function getContentPromise(){
				const deferred = $q.defer();
				LOG.debug('Getting content of file: ' + dataDir.nativeURL + '/' + filePath);
				dataDir.getFile(filePath, null,
					// Got file
					function(fileEntry){
						fileEntry.file((file) => {
							var reader = new FileReader();
							reader.onloadend = function (evt) {
								deferred.resolve(evt.target.result);
							};

							reader.readAsText(file);
						}, () => {
							deferred.reject(SynthError(1004));
						});
					},
					// Failed to get file
					() => {
						deferred.reject(SynthError(1004));
					});
				return deferred.promise;
			}

			return getToolDataDirPromise()
				.then(getContentPromise);
		}

		/**
		 * Gets a file as an object
		 */
		getFileAsObject(fileEntry){
			const deferred = $q.defer();
			fileEntry.file((file) => {
				var reader = new FileReader();
				reader.onloadend = function (evt) {
					if (evt.target.result === ''){
						deferred.resolve({});
					}
					else{
						try {
							LOG.debug('File content for ' + fileEntry.toURL());
							LOG.debug(evt.target.result);
							deferred.resolve(angular.fromJson(evt.target.result));
						}
						catch(e){
							LOG.warn('Failed to create object from json string : \n' + evt.target.result);
							deferred.reject(SynthError(1004));
						}
					}
				};
				reader.readAsText(file);
			}, fail);

			function fail(error) {
				LOG.warn('Failed getting file as object, error : ' + error);
				deferred.reject(SynthError(1004, error));
			}
			return deferred.promise;
		}

		/**
		 * Checks if a file exist
		 */
		doesFileExist(filePath){
			const deferred = $q.defer();
			this.getApplicationRoot().then(
				// Success
				function(fileSystem){
					fileSystem.getFile(filePath, { create : false },
						// It exists
						() => {
							deferred.resolve(true);
						},
						// It does not exist
						() => {
							deferred.resolve(false);
						});
				},
				// Failed
				(error) => {
					deferred.reject(error);
				}
			);

			return deferred.promise;
		}

		/**
		 * This method will delete ALL application data from the user's device.
		 */
		deleteAllApplicationData(){
			const deferred = $q.defer();
			UserSession.clearSession();
			this.getApplicationRoot().then(
				// Success
				(dataDirectoryEntry) => {
					dataDirectoryEntry.removeRecursively(
						() => {
							// Broadcast that all caches should be cleared
							$rootScope.$broadcast('app.clearAllCache');
							deferred.resolve();
						},
						() => {
							deferred.reject(SynthError(1004, 'Error deleting all aplication data'));
						});
				},
				// Failed
				(error) => {
					deferred.reject(error);
				});

			return deferred.promise;
		}

	}

	return new DataServiceImpl();
};
DataService.$inject = ['$q', '$http', '$rootScope', 'LoggerService', 'SynthError', 'UserSession', 'SynthConfig', 'Lock'];
export default DataService;