From 6c19fd1b0e2bee75218762a58dcb925a295992f8 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 8 Nov 2017 14:48:34 +0100 Subject: [PATCH] MOBILE-2261 file: Implement file provider and its Mock --- package-lock.json | 63 +- package.json | 4 + src/app/app.module.ts | 4 +- src/core/emulator/emulator.module.ts | 41 +- src/core/emulator/providers/file.ts | 711 ++++++++++++++++++++ src/core/emulator/providers/helper.ts | 49 ++ src/core/emulator/providers/zip.ts | 86 +++ src/providers/file.ts | 925 ++++++++++++++++++++++++++ 8 files changed, 1874 insertions(+), 9 deletions(-) create mode 100644 src/core/emulator/providers/file.ts create mode 100644 src/core/emulator/providers/helper.ts create mode 100644 src/core/emulator/providers/zip.ts create mode 100644 src/providers/file.ts diff --git a/package-lock.json b/package-lock.json index dd9aea04f..585ab1bc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,11 @@ "resolved": "https://registry.npmjs.org/@ionic-native/core/-/core-4.3.0.tgz", "integrity": "sha512-Pf0qCzqlVFmIpZpvo35Kl0e+1K8GUgPMcKBnN57gWh+5Ecj3dPcb+MbP4murJo/dnFsIYPYdXRZRf74hjo6gtw==" }, + "@ionic-native/file": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@ionic-native/file/-/file-4.3.3.tgz", + "integrity": "sha512-r273zw1gkgGTmlapyJnh31Yemt1P8u1CnTtiZGr3oC/BdlSvLppR7ONW7KbsAxA31UIDAXG+mXP3EqEP2AtVvw==" + }, "@ionic-native/globalization": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/@ionic-native/globalization/-/globalization-4.3.2.tgz", @@ -99,6 +104,11 @@ "resolved": "https://registry.npmjs.org/@ionic-native/status-bar/-/status-bar-4.3.0.tgz", "integrity": "sha512-gjS0U2uT6XYshysvzNu98Pf6b5SZ7SGSYkZW1mft19geFn6/MKunX1CJkjpXmiTn14nAD1+FBxF43Oi2OfoM4g==" }, + "@ionic-native/zip": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@ionic-native/zip/-/zip-4.3.3.tgz", + "integrity": "sha512-QFmRDV6PDGkPDCMcD2cyk6XUvsYjn3T3QphCORxmWj+7szN7AXzfpmhkSMysBY7I5N3yjtNkRJY0XAIMTV/wLA==" + }, "@ionic/app-scripts": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@ionic/app-scripts/-/app-scripts-3.0.0.tgz", @@ -125,6 +135,16 @@ "resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz", "integrity": "sha1-6nrd907Ow9dimCegw54smt3HPQQ=" }, + "@types/cordova-plugin-file": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/cordova-plugin-file/-/cordova-plugin-file-0.0.3.tgz", + "integrity": "sha1-HogEIYKUSk4zfbjQ5stA7FeFDKo=" + }, + "@types/cordova-plugin-file-transfer": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/cordova-plugin-file-transfer/-/cordova-plugin-file-transfer-0.0.3.tgz", + "integrity": "sha1-u6d+jQTQejlRR5eiA8JXxbECNoA=" + }, "@types/cordova-plugin-globalization": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/@types/cordova-plugin-globalization/-/cordova-plugin-globalization-0.0.3.tgz", @@ -837,6 +857,11 @@ "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=", "dev": true }, + "core-js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.3.0.tgz", + "integrity": "sha1-+rg/uwstjchfpjbEudNMdUIMbWU=" + }, "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -1149,6 +1174,11 @@ "integrity": "sha1-kTbgUD3MBqMBaQ8LsU/042TpSfA=", "dev": true }, + "es6-promise": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.0.2.tgz", + "integrity": "sha1-AQ1YWEI6XxGJeWZfRkhqlcbuK7Y=" + }, "es6-set": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/es6-set/-/es6-set-0.1.5.tgz", @@ -2905,6 +2935,33 @@ } } }, + "jszip": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.1.4.tgz", + "integrity": "sha512-z6w8iYIxZ/fcgul0j/OerkYnkomH8BZigvzbxVmr2h5HkZUrPtk2kjYtLkqR9wwQxEP6ecKNoKLsbhd18jfnGA==", + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=" + }, + "pako": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.6.tgz", + "integrity": "sha512-lQe48YPsMJAig+yngZ87Lus+NF+3mtu7DVOBu6b/gHO1YpKwIj5AWjZ/TOS7i46HD/UixzWb1zeWDZfGZ3iYcg==" + }, + "readable-stream": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz", + "integrity": "sha1-j5A0HmilPMySh4jaz80Rs265t44=" + } + } + }, "kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -3882,8 +3939,7 @@ "process-nextick-args": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", - "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=", - "dev": true + "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=" }, "promise.prototype.finally": { "version": "3.0.1", @@ -4863,8 +4919,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "utils-merge": { "version": "1.0.0", diff --git a/package.json b/package.json index a82a1d411..a7c44e25b 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@angular/platform-browser-dynamic": "4.4.3", "@ionic-native/clipboard": "^4.3.2", "@ionic-native/core": "4.3.0", + "@ionic-native/file": "^4.3.3", "@ionic-native/globalization": "^4.3.2", "@ionic-native/in-app-browser": "^4.3.3", "@ionic-native/keyboard": "^4.3.2", @@ -43,10 +44,12 @@ "@ionic-native/splash-screen": "4.3.0", "@ionic-native/sqlite": "^4.3.2", "@ionic-native/status-bar": "4.3.0", + "@ionic-native/zip": "^4.3.3", "@ionic/storage": "2.0.1", "@ngx-translate/core": "^8.0.0", "@ngx-translate/http-loader": "^2.0.0", "@types/cordova": "0.0.34", + "@types/cordova-plugin-file-transfer": "0.0.3", "@types/cordova-plugin-globalization": "0.0.3", "@types/cordova-plugin-network-information": "0.0.3", "@types/node": "^8.0.47", @@ -55,6 +58,7 @@ "electron-windows-notifications": "^1.1.13", "ionic-angular": "3.7.1", "ionicons": "3.0.0", + "jszip": "^3.1.4", "moment": "^2.19.1", "promise.prototype.finally": "^3.0.1", "rxjs": "5.4.3", diff --git a/src/app/app.module.ts b/src/app/app.module.ts index f06925b83..323c0a050 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -24,6 +24,7 @@ import { CoreUrlUtilsProvider } from '../providers/utils/url'; import { CoreUtilsProvider } from '../providers/utils/utils'; import { CoreMimetypeUtilsProvider } from '../providers/utils/mimetype'; import { CoreInitDelegate } from '../providers/init'; +import { CoreFileProvider } from '../providers/file'; // For translate loader. AoT requires an exported function for factories. export function createTranslateLoader(http: HttpClient) { @@ -68,7 +69,8 @@ export function createTranslateLoader(http: HttpClient) { CoreUrlUtilsProvider, CoreUtilsProvider, CoreMimetypeUtilsProvider, - CoreInitDelegate + CoreInitDelegate, + CoreFileProvider ] }) export class AppModule { diff --git a/src/core/emulator/emulator.module.ts b/src/core/emulator/emulator.module.ts index 438078fc8..5a831d0d0 100644 --- a/src/core/emulator/emulator.module.ts +++ b/src/core/emulator/emulator.module.ts @@ -15,21 +15,31 @@ import { NgModule } from '@angular/core'; import { Platform } from 'ionic-angular'; -import { CoreAppProvider } from '../../providers/app'; import { Clipboard } from '@ionic-native/clipboard'; +import { File } from '@ionic-native/file'; import { Globalization } from '@ionic-native/globalization'; import { Network } from '@ionic-native/network'; +import { Zip } from '@ionic-native/zip'; import { ClipboardMock } from './providers/clipboard'; +import { FileMock} from './providers/file'; import { GlobalizationMock } from './providers/globalization'; import { NetworkMock } from './providers/network'; +import { ZipMock } from './providers/zip'; import { InAppBrowser } from '@ionic-native/in-app-browser'; +import { CoreEmulatorHelper } from './providers/helper'; +import { CoreAppProvider } from '../../providers/app'; +import { CoreTextUtilsProvider } from '../../providers/utils/text'; +import { CoreMimetypeUtilsProvider } from '../../providers/utils/mimetype'; +import { CoreInitDelegate } from '../../providers/init'; + @NgModule({ declarations: [ ], imports: [ ], providers: [ + CoreEmulatorHelper, ClipboardMock, GlobalizationMock, { @@ -39,6 +49,14 @@ import { InAppBrowser } from '@ionic-native/in-app-browser'; return appProvider.isMobile() ? new Clipboard() : new ClipboardMock(appProvider); } }, + { + provide: File, + deps: [CoreAppProvider, CoreTextUtilsProvider], + useFactory: (appProvider: CoreAppProvider, textUtils: CoreTextUtilsProvider) => { + // Use platform instead of CoreAppProvider to prevent circular dependencies. + return appProvider.isMobile() ? new File() : new FileMock(appProvider, textUtils); + } + }, { provide: Globalization, deps: [CoreAppProvider], @@ -54,18 +72,33 @@ import { InAppBrowser } from '@ionic-native/in-app-browser'; return platform.is('cordova') ? new Network() : new NetworkMock(); } }, + { + provide: Zip, + deps: [CoreAppProvider, File, CoreMimetypeUtilsProvider, CoreTextUtilsProvider], + useFactory: (appProvider: CoreAppProvider, file: File, mimeUtils: CoreMimetypeUtilsProvider) => { + // Use platform instead of CoreAppProvider to prevent circular dependencies. + return appProvider.isMobile() ? new Zip() : new ZipMock(file, mimeUtils); + } + }, InAppBrowser ] }) export class CoreEmulatorModule { - constructor(appProvider: CoreAppProvider) { + constructor(appProvider: CoreAppProvider, initDelegate: CoreInitDelegate, helper: CoreEmulatorHelper) { let win = window; // Convert the "window" to "any" type to be able to use non-standard properties. // Emulate Custom URL Scheme plugin in desktop apps. if (appProvider.isDesktop()) { - require('electron').ipcRenderer.on('mmAppLaunched', function(event, url) { - win.handleOpenURL && win.handleOpenURL(url); + require('electron').ipcRenderer.on('mmAppLaunched', (event, url) => { + if (typeof win.handleOpenURL != 'undefined') { + win.handleOpenURL(url); + } }); } + + if (!appProvider.isMobile()) { + // Register an init process to load the Mocks that need it. + initDelegate.registerProcess(helper); + } } } diff --git a/src/core/emulator/providers/file.ts b/src/core/emulator/providers/file.ts new file mode 100644 index 000000000..729b1fae5 --- /dev/null +++ b/src/core/emulator/providers/file.ts @@ -0,0 +1,711 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { File, Entry, DirectoryEntry, FileEntry, FileError, IWriteOptions } from '@ionic-native/file'; +import { CoreAppProvider } from '../../../providers/app'; +import { CoreTextUtilsProvider } from '../../../providers/utils/text'; +import { CoreConfigConstants } from '../../../configconstants'; + +/** + * Emulates the Cordova File plugin in desktop apps and in browser. + * Most of the code is extracted from the File class of Ionic Native. + */ +@Injectable() +export class FileMock extends File { + + constructor(private appProvider: CoreAppProvider, private textUtils: CoreTextUtilsProvider) { + super(); + } + + /** + * Check if a directory exists in a certain path, directory. + * + * @param {string} path Base FileSystem. + * @param {string} dir Name of directory to check + * @returns {Promise} Returns a Promise that resolves to true if the directory exists or rejects with an error. + */ + checkDir(path: string, dir: string) : Promise { + let fullPath = this.textUtils.concatenatePaths(path, dir); + return this.resolveDirectoryUrl(fullPath).then(() => { + return true; + }); + } + + /** + * Check if a file exists in a certain path, directory. + * + * @param {string} path Base FileSystem. + * @param {string} file Name of file to check. + * @returns {Promise} Returns a Promise that resolves with a boolean or rejects with an error. + */ + checkFile(path: string, file: string): Promise { + return this.resolveLocalFilesystemUrl(this.textUtils.concatenatePaths(path, file)).then((fse) => { + if (fse.isFile) { + return true; + } else { + let err = new FileError(13); + err.message = 'input is not a file'; + return Promise.reject(err); + } + }); + } + + /** + * @hidden + */ + private copyMock(srce: Entry, destdir: DirectoryEntry, newName: string): Promise { + return new Promise((resolve, reject) => { + srce.copyTo(destdir, newName, (deste) => { + resolve(deste); + }, (err) => { + this.fillErrorMessageMock(err); + reject(err); + }); + }); + } + + /** + * Copy a directory in various methods. If destination directory exists, will fail to copy. + * + * @param {string} path Base FileSystem. Please refer to the iOS and Android filesystems above. + * @param {string} dirName Name of directory to copy. + * @param {string} newPath Base FileSystem of new location. + * @param {string} newDirName New name of directory to copy to (leave blank to remain the same). + * @returns {Promise} Returns a Promise that resolves to the new Entry object or rejects with an error. + */ + copyDir(path: string, dirName: string, newPath: string, newDirName: string): Promise { + return this.resolveDirectoryUrl(path).then((fse) => { + return this.getDirectory(fse, dirName, { create: false }); + }).then((srcde) => { + return this.resolveDirectoryUrl(newPath).then((deste) => { + return this.copyMock(srcde, deste, newDirName); + }); + }); + } + + /** + * Copy a file in various methods. If file exists, will fail to copy. + * + * @param {string} path Base FileSystem. Please refer to the iOS and Android filesystems above + * @param {string} fileName Name of file to copy + * @param {string} newPath Base FileSystem of new location + * @param {string} newFileName New name of file to copy to (leave blank to remain the same) + * @returns {Promise} Returns a Promise that resolves to an Entry or rejects with an error. + */ + copyFile(path: string, fileName: string, newPath: string, newFileName: string): Promise { + newFileName = newFileName || fileName; + + return this.resolveDirectoryUrl(path).then((fse) => { + return this.getFile(fse, fileName, { create: false }); + }).then((srcfe) => { + return this.resolveDirectoryUrl(newPath).then((deste) => { + return this.copyMock(srcfe, deste, newFileName); + }); + }); + } + + /** + * Creates a new directory in the specific path. + * The replace boolean value determines whether to replace an existing directory with the same name. + * If an existing directory exists and the replace value is false, the promise will fail and return an error. + * + * @param {string} path Base FileSystem. + * @param {string} dirName Name of directory to create + * @param {boolean} replace If true, replaces file with same name. If false returns error + * @returns {Promise} Returns a Promise that resolves with a DirectoryEntry or rejects with an error. + */ + createDir(path: string, dirName: string, replace: boolean): Promise { + let options: any = { + create: true + }; + + if (!replace) { + options.exclusive = true; + } + + return this.resolveDirectoryUrl(path).then((fse) => { + return this.getDirectory(fse, dirName, options); + }); + } + + /** + * Creates a new file in the specific path. + * The replace boolean value determines whether to replace an existing file with the same name. + * If an existing file exists and the replace value is false, the promise will fail and return an error. + * + * @param {string} path Base FileSystem. + * @param {string} fileName Name of file to create. + * @param {boolean} replace If true, replaces file with same name. If false returns error. + * @returns {Promise} Returns a Promise that resolves to a FileEntry or rejects with an error. + */ + createFile(path: string, fileName: string, replace: boolean): Promise { + let options: any = { + create: true + }; + + if (!replace) { + options.exclusive = true; + } + + return this.resolveDirectoryUrl(path).then((fse) => { + return this.getFile(fse, fileName, options); + }); + } + + /** + * @hidden + */ + private createWriterMock(fe: FileEntry): Promise { + return new Promise((resolve, reject) => { + fe.createWriter((writer) => { + resolve(writer); + }, (err) => { + this.fillErrorMessageMock(err); + reject(err); + }); + }); + } + + /** + * @hidden + */ + private fillErrorMessageMock(err: any): void { + try { + err.message = this.cordovaFileError[err.code]; + } catch (e) { } + } + + /** + * Get a directory. + * + * @param directoryEntry {DirectoryEntry} Directory entry, obtained by resolveDirectoryUrl method + * @param directoryName {string} Directory name + * @param flags {Flags} Options + * @returns {Promise} + */ + getDirectory(directoryEntry: DirectoryEntry, directoryName: string, flags: Flags): Promise { + return new Promise((resolve, reject) => { + try { + directoryEntry.getDirectory(directoryName, flags, (de) => { + resolve(de); + }, (err) => { + this.fillErrorMessageMock(err); + reject(err); + }); + } catch (xc) { + this.fillErrorMessageMock(xc); + reject(xc); + } + }); + } + + /** + * Get a file + * @param directoryEntry {DirectoryEntry} Directory entry, obtained by resolveDirectoryUrl method + * @param fileName {string} File name + * @param flags {Flags} Options + * @returns {Promise} + */ + getFile(directoryEntry: DirectoryEntry, fileName: string, flags: Flags): Promise { + return new Promise((resolve, reject) => { + try { + directoryEntry.getFile(fileName, flags, resolve, (err) => { + this.fillErrorMessageMock(err); + reject(err); + }); + } catch (xc) { + this.fillErrorMessageMock(xc); + reject(xc); + } + }); + } + + /** + * Get free disk space. + * + * @return {Promise} Promise resolved with the free space. + */ + getFreeDiskSpace() : Promise { + // FRequest a file system instance with a minimum size until we get an error. + if (window.requestFileSystem) { + return new Promise((resolve, reject) => { + let iterations = 0, + maxIterations = 50, + calculateByRequest = (size: number, ratio: number) => { + return new Promise((resolve, reject) => { + window.requestFileSystem(LocalFileSystem.PERSISTENT, size, () => { + iterations++; + if (iterations > maxIterations) { + resolve(size); + return; + } + calculateByRequest(size * ratio, ratio).then(resolve); + }, () => { + resolve(size / ratio); + }); + }); + }; + + // General calculation, base 1MB and increasing factor 1.3. + calculateByRequest(1048576, 1.3).then((size: number) => { + iterations = 0; + maxIterations = 10; + // More accurate. Factor is 1.1. + calculateByRequest(size, 1.1).then((size: number) => { + return size / 1024; // Return size in KB. + }); + }); + + }); + } else { + return Promise.reject(null); + } + } + + /** + * List files and directory from a given path. + * + * @param {string} path Base FileSystem. Please refer to the iOS and Android filesystems above + * @param {string} dirName Name of directory + * @returns {Promise} Returns a Promise that resolves to an array of Entry objects or rejects with an error. + */ + listDir(path: string, dirName: string): Promise { + return this.resolveDirectoryUrl(path).then((fse) => { + return this.getDirectory(fse, dirName, { create: false, exclusive: false }); + }).then((de) => { + let reader: any = de.createReader(); + return this.readEntriesMock(reader); + }); + } + + /** + * Loads an initialize the API for browser and desktop. + * + * @return {Promise} Promise resolved when loaded. + */ + load() : Promise { + return new Promise((resolve, reject) => { + let basePath, + win = window; // Convert to to be able to use non-standard properties. + + if (typeof win.requestFileSystem == 'undefined') { + win.requestFileSystem = win.webkitRequestFileSystem; + } + if (typeof win.resolveLocalFileSystemURL == 'undefined') { + win.resolveLocalFileSystemURL = win.webkitResolveLocalFileSystemURL; + } + win.LocalFileSystem = { + PERSISTENT: 1 + }; + + if (this.appProvider.isDesktop()) { + let fs = require('fs'), + app = require('electron').remote.app; + + // emulateCordovaFileForDesktop(fs); + + // Initialize File System. Get the path to use. + basePath = app.getPath('documents') || app.getPath('home'); + if (!basePath) { + reject('Cannot calculate base path for file system.'); + return; + } + + basePath = this.textUtils.concatenatePaths(basePath.replace(/\\/g, '/'), CoreConfigConstants.app_id) + '/'; + + // Create the folder if needed. + fs.mkdir(basePath, (e) => { + if (!e || (e && e.code === 'EEXIST')) { + // Create successful or it already exists. Resolve. + // this.fileProvider.setHTMLBasePath(basePath); + resolve(basePath); + } else { + reject('Error creating base path.'); + } + }); + } else { + // It's browser, request a quota to use. Request 500MB. + (navigator).webkitPersistentStorage.requestQuota(500 * 1024 * 1024, (granted) => { + window.requestFileSystem(LocalFileSystem.PERSISTENT, granted, (entry) => { + basePath = entry.root.toURL(); + // this.fileProvider.setHTMLBasePath(basePath); + resolve(basePath); + }, reject); + }, reject); + } + + }); + } + + /** + * @hidden + */ + private moveMock(srce: Entry, destdir: DirectoryEntry, newName: string): Promise { + return new Promise((resolve, reject) => { + srce.moveTo(destdir, newName, (deste) => { + resolve(deste); + }, (err) => { + this.fillErrorMessageMock(err); + reject(err); + }); + }); + } + + /** + * Move a directory to a given path. + * + * @param {string} path The source path to the directory. + * @param {string} dirName The source directory name. + * @param {string} newPath The destionation path to the directory. + * @param {string} newDirName The destination directory name. + * @returns {Promise} Returns a Promise that resolves to the new DirectoryEntry object or rejects with an error. + */ + moveDir(path: string, dirName: string, newPath: string, newDirName: string): Promise { + return this.resolveDirectoryUrl(path).then((fse) => { + return this.getDirectory(fse, dirName, { create: false }); + }).then((srcde) => { + return this.resolveDirectoryUrl(newPath).then((deste) => { + return this.moveMock(srcde, deste, newDirName); + }); + }); + } + + /** + * Move a file to a given path. + * + * @param {string} path Base FileSystem. Please refer to the iOS and Android filesystems above + * @param {string} fileName Name of file to move + * @param {string} newPath Base FileSystem of new location + * @param {string} newFileName New name of file to move to (leave blank to remain the same) + * @returns {Promise} Returns a Promise that resolves to the new Entry or rejects with an error. + */ + moveFile(path: string, fileName: string, newPath: string, newFileName: string): Promise { + newFileName = newFileName || fileName; + + return this.resolveDirectoryUrl(path).then((fse) => { + return this.getFile(fse, fileName, { create: false }); + }).then((srcfe) => { + return this.resolveDirectoryUrl(newPath).then((deste) => { + return this.moveMock(srcfe, deste, newFileName); + }); + }); + } + + /** + * Read file and return data as an ArrayBuffer. + * @param {string} path Base FileSystem. + * @param {string} file Name of file, relative to path. + * @returns {Promise} Returns a Promise that resolves with the contents of the file as ArrayBuffer or rejects with an error. + */ + readAsArrayBuffer(path: string, file: string): Promise { + return this.readFileMock(path, file, 'ArrayBuffer'); + } + + /** + * Read file and return data as a binary data. + * @param {string} path Base FileSystem. + * @param {string} file Name of file, relative to path. + * @returns {Promise} Returns a Promise that resolves with the contents of the file as string rejects with an error. + */ + readAsBinaryString(path: string, file: string): Promise { + return this.readFileMock(path, file, 'BinaryString'); + } + + /** + * Read file and return data as a base64 encoded data url. + * A data url is of the form: + * data: [][;base64], + + * @param {string} path Base FileSystem. + * @param {string} file Name of file, relative to path. + * @returns {Promise} Returns a Promise that resolves with the contents of the file as data URL or rejects with an error. + */ + readAsDataURL(path: string, file: string): Promise { + return this.readFileMock(path, file, 'DataURL'); + } + + /** + * Read the contents of a file as text. + * + * @param {string} path Base FileSystem. + * @param {string} file Name of file, relative to path. + * @returns {Promise} Returns a Promise that resolves with the contents of the file as string or rejects with an error. + */ + readAsText(path: string, file: string): Promise { + return this.readFileMock(path, file, 'Text'); + } + + /** + * @hidden + */ + private readEntriesMock(dr: DirectoryReader): Promise { + return new Promise((resolve, reject) => { + dr.readEntries((entries: any) => { + resolve(entries); + }, (err) => { + this.fillErrorMessageMock(err); + reject(err); + }); + }); + } + + /** + * Read the contents of a file. + * + * @param {string} path Base FileSystem. + * @param {string} file Name of file, relative to path. + * @param {string} readAs Format to read as. + * @returns {Promise} Returns a Promise that resolves with the contents of the file or rejects with an error. + */ + private readFileMock(path: string, file: string, readAs: 'ArrayBuffer' | 'BinaryString' | 'DataURL' | 'Text'): Promise { + return this.resolveDirectoryUrl(path).then((directoryEntry: DirectoryEntry) => { + return this.getFile(directoryEntry, file, { create: false }); + }).then((fileEntry: FileEntry) => { + const reader = new FileReader(); + return new Promise((resolve, reject) => { + reader.onloadend = () => { + if (reader.result !== undefined || reader.result !== null) { + resolve(reader.result); + } else if (reader.error !== undefined || reader.error !== null) { + reject(reader.error); + } else { + reject({ code: null, message: 'READER_ONLOADEND_ERR' }); + } + }; + + fileEntry.file(file => { + reader[`readAs${readAs}`].call(reader, file); + }, error => { + reject(error); + }); + }); + }); + } + + /** + * @hidden + */ + private removeMock(fe: Entry): Promise { + return new Promise((resolve, reject) => { + fe.remove(() => { + resolve({ success: true, fileRemoved: fe }); + }, (err) => { + this.fillErrorMessageMock(err); + reject(err); + }); + }); + } + + /** + * Remove a directory at a given path. + * + * @param {string} path The path to the directory. + * @param {string} dirName The directory name. + * @returns {Promise} Returns a Promise that resolves to a RemoveResult or rejects with an error. + */ + removeDir(path: string, dirName: string): Promise { + return this.resolveDirectoryUrl(path).then((fse) => { + return this.getDirectory(fse, dirName, { create: false }); + }).then((de) => { + return this.removeMock(de); + }); + } + + /** + * Removes a file from a desired location. + * + * @param {string} path Base FileSystem. + * @param {string} fileName Name of file to remove. + * @returns {Promise} Returns a Promise that resolves to a RemoveResult or rejects with an error. + */ + removeFile(path: string, fileName: string): Promise { + return this.resolveDirectoryUrl(path).then((fse) => { + return this.getFile(fse, fileName, { create: false }); + }).then((fe) => { + return this.removeMock(fe); + }); + } + + /** + * Removes all files and the directory from a desired location. + * + * @param {string} path Base FileSystem. Please refer to the iOS and Android filesystems above + * @param {string} dirName Name of directory + * @returns {Promise} Returns a Promise that resolves with a RemoveResult or rejects with an error. + */ + removeRecursively(path: string, dirName: string): Promise { + return this.resolveDirectoryUrl(path).then((fse) => { + return this.getDirectory(fse, dirName, { create: false }); + }).then((de) => { + return this.rimrafMock(de); + }); + } + + /** + * Resolves a local directory url + * @param directoryUrl {string} directory system url + * @returns {Promise} + */ + resolveDirectoryUrl(directoryUrl: string): Promise { + return this.resolveLocalFilesystemUrl(directoryUrl).then((de) => { + if (de.isDirectory) { + return de; + } else { + const err = new FileError(13); + err.message = 'input is not a directory'; + return Promise.reject(err); + } + }); + } + + /** + * Resolves a local file system URL + * @param fileUrl {string} file system url + * @returns {Promise} + */ + resolveLocalFilesystemUrl(fileUrl: string): Promise { + return new Promise((resolve, reject) => { + try { + window.resolveLocalFileSystemURL(fileUrl, (entry: any) => { + resolve(entry); + }, (err) => { + this.fillErrorMessageMock(err); + reject(err); + }); + } catch (xc) { + this.fillErrorMessageMock(xc); + reject(xc); + } + }); + } + + /** + * @hidden + */ + private rimrafMock(de: DirectoryEntry): Promise { + return new Promise((resolve, reject) => { + de.removeRecursively(() => { + resolve({ success: true, fileRemoved: de }); + }, (err) => { + this.fillErrorMessageMock(err); + reject(err); + }); + }); + } + + /** + * @hidden + */ + private writeMock(writer: FileWriter, gu: any): Promise { + if (gu instanceof Blob) { + return this.writeFileInChunksMock(writer, gu); + } + + return new Promise((resolve, reject) => { + writer.onwriteend = (evt) => { + if (writer.error) { + reject(writer.error); + } else { + resolve(evt); + } + }; + writer.write(gu); + }); + } + + /** + * Write to an existing file. + * + * @param {string} path Base FileSystem. + * @param {string} fileName path relative to base path. + * @param {string | Blob} text content or blob to write. + * @returns {Promise} Returns a Promise that resolves or rejects with an error. + */ + writeExistingFile(path: string, fileName: string, text: string | Blob): Promise { + return this.writeFile(path, fileName, text, { replace: true }); + } + + /** Write a new file to the desired location. + * + * @param {string} path Base FileSystem. Please refer to the iOS and Android filesystems above + * @param {string} fileName path relative to base path + * @param {string | Blob} text content or blob to write + * @param {any} options replace file if set to true. See WriteOptions for more information. + * @returns {Promise} Returns a Promise that resolves to updated file entry or rejects with an error. + */ + writeFile(path: string, fileName: string, text: string | Blob | ArrayBuffer, options: IWriteOptions = {}): Promise { + const getFileOpts: any = { + create: !options.append, + exclusive: !options.replace + }; + + return this.resolveDirectoryUrl(path).then((directoryEntry: DirectoryEntry) => { + return this.getFile(directoryEntry, fileName, getFileOpts); + }).then((fileEntry: FileEntry) => { + return this.writeFileEntryMock(fileEntry, text, options); + }); + } + + /** + * Write content to FileEntry. + * + * @hidden + * @param {FileEntry} fe File entry object. + * @param {string | Blob} text Content or blob to write. + * @param {IWriteOptions} options replace file if set to true. See WriteOptions for more information. + * @returns {Promise} Returns a Promise that resolves to updated file entry or rejects with an error. + */ + private writeFileEntryMock(fe: FileEntry, text: string | Blob | ArrayBuffer, options: IWriteOptions) { + return this.createWriterMock(fe).then((writer) => { + if (options.append) { + writer.seek(writer.length); + } + + if (options.truncate) { + writer.truncate(options.truncate); + } + + return this.writeMock(writer, text); + }).then(() => fe); + } + + /** + * @hidden + */ + private writeFileInChunksMock(writer: FileWriter, file: Blob) { + const BLOCK_SIZE = 1024 * 1024; + let writtenSize = 0; + + function writeNextChunk() { + const size = Math.min(BLOCK_SIZE, file.size - writtenSize); + const chunk = file.slice(writtenSize, writtenSize + size); + + writtenSize += size; + writer.write(chunk); + } + + return new Promise((resolve, reject) => { + writer.onerror = reject; + writer.onwrite = () => { + if (writtenSize < file.size) { + writeNextChunk(); + } else { + resolve(); + } + }; + writeNextChunk(); + }); + } +} diff --git a/src/core/emulator/providers/helper.ts b/src/core/emulator/providers/helper.ts new file mode 100644 index 000000000..96780ca86 --- /dev/null +++ b/src/core/emulator/providers/helper.ts @@ -0,0 +1,49 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreFileProvider } from '../../../providers/file'; +import { CoreUtilsProvider } from '../../../providers/utils/utils'; +import { File } from '@ionic-native/file'; +import { CoreInitDelegate, CoreInitHandler } from '../../../providers/init'; + +/** + * Emulates the Cordova Zip plugin in desktop apps and in browser. + */ +@Injectable() +export class CoreEmulatorHelper implements CoreInitHandler { + name = 'CoreEmulator'; + priority; + blocking = true; + + constructor(private file: File, private fileProvider: CoreFileProvider, private utils: CoreUtilsProvider, + initDelegate: CoreInitDelegate) { + this.priority = initDelegate.MAX_RECOMMENDED_PRIORITY + 500; + } + + /** + * Load the Mocks that need it. + * + * @return {Promise} Promise resolved when loaded. + */ + load() : Promise { + let promises = []; + + promises.push((this.file).load().then((basePath: string) => { + this.fileProvider.setHTMLBasePath(basePath); + })); + + return this.utils.allPromises(promises); + } +} diff --git a/src/core/emulator/providers/zip.ts b/src/core/emulator/providers/zip.ts new file mode 100644 index 000000000..f83846b9c --- /dev/null +++ b/src/core/emulator/providers/zip.ts @@ -0,0 +1,86 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { Zip } from '@ionic-native/zip'; +import { JSZip } from 'jszip'; +import { File } from '@ionic-native/file'; +import { CoreMimetypeUtilsProvider } from '../../../providers/utils/mimetype'; + +/** + * Emulates the Cordova Zip plugin in desktop apps and in browser. + */ +@Injectable() +export class ZipMock extends Zip { + + constructor(private file: File, private mimeUtils: CoreMimetypeUtilsProvider) { + super(); + } + + /** + * Extracts files from a ZIP archive. + * + * @param {string} source Path to the source ZIP file. + * @param {string} destination Destination folder. + * @param {Function} [onProgress] Optional callback to be called on progress update + * @return {Promise} Promise that resolves with a number. 0 is success, -1 is error. + */ + unzip(source: string, destination: string, onProgress?: Function) : Promise { + // Replace all %20 with spaces. + source = source.replace(/%20/g, ' '); + destination = destination.replace(/%20/g, ' '); + + let sourceDir = source.substring(0, source.lastIndexOf('/')), + sourceName = source.substr(source.lastIndexOf('/') + 1); + + return this.file.readAsArrayBuffer(sourceDir, sourceName).then((data) => { + let zip = new JSZip(data), + promises = [], + loaded = 0, + total = Object.keys(zip.files).length; + + if (!zip.files || !zip.files.length) { + // Nothing to extract. + return 0; + } + + zip.files.forEach((file, name) => { + let type, + promise; + + if (!file.dir) { + // It's a file. Get the mimetype and write the file. + type = this.mimeUtils.getMimeType(this.mimeUtils.getFileExtension(name)); + promise = this.file.writeFile(destination, name, new Blob([file.asArrayBuffer()], {type: type})); + } else { + // It's a folder, create it if it doesn't exist. + promise = this.file.createDir(destination, name, false); + } + + promises.push(promise.then(() => { + // File unzipped, call the progress. + loaded++; + onProgress && onProgress({loaded: loaded, total: total}); + })); + }); + + return Promise.all(promises).then(function() { + return 0; + }); + }).catch(function() { + // Error. + return -1; + }); + } +} diff --git a/src/providers/file.ts b/src/providers/file.ts new file mode 100644 index 000000000..a30e71aec --- /dev/null +++ b/src/providers/file.ts @@ -0,0 +1,925 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { Platform } from 'ionic-angular'; +import { File, FileEntry, DirectoryEntry } from '@ionic-native/file'; + +import { CoreAppProvider } from './app'; +import { CoreLoggerProvider } from './logger'; +import { CoreMimetypeUtilsProvider } from './utils/mimetype'; +import { CoreTextUtilsProvider } from './utils/text'; +import { Zip } from '@ionic-native/zip'; + +/** + * Factory to interact with the file system. + */ +@Injectable() +export class CoreFileProvider { + logger; + initialized = false; + basePath = ''; + isHTMLAPI = false; + + // Formats to read a file. + FORMATTEXT = 0; + FORMATDATAURL = 1; + FORMATBINARYSTRING = 2; + FORMATARRAYBUFFER = 3; + + // Folders. + SITESFOLDER = 'sites'; + TMPFOLDER = 'tmp'; + + constructor(logger: CoreLoggerProvider, private platform: Platform, private file: File, private appProvider: CoreAppProvider, + private textUtils: CoreTextUtilsProvider, private zip: Zip, private mimeUtils: CoreMimetypeUtilsProvider) { + this.logger = logger.getInstance('CoreFileProvider'); + } + + /** + * Sets basePath to use with HTML API. Reserved for core use. + * + * @param {string} path Base path to use. + */ + setHTMLBasePath(path: string) { + this.isHTMLAPI = true; + this.basePath = path; + } + + /** + * Checks if we're using HTML API. + * + * @return {boolean} True if uses HTML API, false otherwise. + */ + usesHTMLAPI() : boolean { + return this.isHTMLAPI; + } + + /** + * Initialize basePath based on the OS if it's not initialized already. + * + * @return {Promise} Promise to be resolved when the initialization is finished. + */ + init() : Promise { + if (this.initialized) { + return Promise.resolve(); + } + + return this.platform.ready().then(() => { + + if (this.platform.is('android')) { + this.basePath = this.file.externalApplicationStorageDirectory; + } else if (this.platform.is('ios')) { + this.basePath = this.file.documentsDirectory; + } else if (!this.isAvailable() || this.basePath === '') { + this.logger.error('Error getting device OS.'); + return Promise.reject(null); + } + + this.initialized = true; + this.logger.debug('FS initialized: ' + this.basePath); + }); + } + + /** + * Check if the plugin is available. + * + * @return {boolean} Whether the plugin is available. + */ + isAvailable() : boolean { + return typeof window.resolveLocalFileSystemURL !== 'undefined' && typeof FileTransfer !== 'undefined'; + } + + /** + * Get a file. + * + * @param {string} path Relative path to the file. + * @return {Promise} Promise resolved when the file is retrieved. + */ + getFile(path: string) : Promise { + return this.init().then(() => { + this.logger.debug('Get file: ' + path); + return this.file.resolveLocalFilesystemUrl(this.addBasePathIfNeeded(path)); + }).then((entry) => { + return entry; + }); + } + + /** + * Get a directory. + * + * @param {string} path Relative path to the directory. + * @return {Promise} Promise resolved when the directory is retrieved. + */ + getDir(path: string) : Promise { + return this.init().then(() => { + this.logger.debug('Get directory: ' + path); + return this.file.resolveDirectoryUrl(this.addBasePathIfNeeded(path)); + }); + } + + /** + * Get site folder path. + * + * @param {string} siteId Site ID. + * @return {string} Site folder path. + */ + getSiteFolder(siteId: string) : string { + return this.SITESFOLDER + '/' + siteId; + } + + /** + * Create a directory or a file. + * + * @param {boolean} isDirectory True if a directory should be created, false if it should create a file. + * @param {string} path Relative path to the dir/file. + * @param {boolean} [failIfExists] True if it should fail if the dir/file exists, false otherwise. + * @param {string} [base] Base path to create the dir/file in. If not set, use basePath. + * @return {Promise} Promise to be resolved when the dir/file is created. + */ + protected create(isDirectory: boolean, path: string, failIfExists?: boolean, base?: string) : Promise { + return this.init().then(() => { + // Remove basePath if it's in the path. + path = this.removeStartingSlash(path.replace(this.basePath, '')); + base = base || this.basePath; + + if (path.indexOf('/') == -1) { + if (isDirectory) { + this.logger.debug('Create dir ' + path + ' in ' + base); + return this.file.createDir(base, path, !failIfExists); + } else { + this.logger.debug('Create file ' + path + ' in ' + base); + return this.file.createFile(base, path, !failIfExists); + } + } else { + // this.file doesn't allow creating more than 1 level at a time (e.g. tmp/folder). + // We need to create them 1 by 1. + let firstDir = path.substr(0, path.indexOf('/')), + restOfPath = path.substr(path.indexOf('/') + 1); + + this.logger.debug('Create dir ' + firstDir + ' in ' + base); + + return this.file.createDir(base, firstDir, true).then((newDirEntry) => { + return this.create(isDirectory, restOfPath, failIfExists, newDirEntry.toURL()); + }).catch((error) => { + this.logger.error('Error creating directory ' + firstDir + ' in ' + base); + return Promise.reject(error); + }); + } + }); + } + + /** + * Create a directory. + * + * @param {string} path Relative path to the directory. + * @param {boolean} [failIfExists] True if it should fail if the directory exists, false otherwise. + * @return {Promise} Promise to be resolved when the directory is created. + */ + createDir(path: string, failIfExists?: boolean) : Promise { + return this.create(true, path, failIfExists); + } + + /** + * Create a file. + * + * @param {string} path Relative path to the file. + * @param {boolean} [failIfExists] True if it should fail if the file exists, false otherwise.. + * @return {Promise} Promise to be resolved when the file is created. + */ + createFile(path: string, failIfExists?: boolean) : Promise { + return this.create(false, path, failIfExists); + } + + /** + * Removes a directory and all its contents. + * + * @param {string} path Relative path to the directory. + * @return {Promise} Promise to be resolved when the directory is deleted. + */ + removeDir(path: string) : Promise { + return this.init().then(() => { + // Remove basePath if it's in the path. + path = this.removeStartingSlash(path.replace(this.basePath, '')); + this.logger.debug('Remove directory: ' + path); + return this.file.removeRecursively(this.basePath, path); + }); + } + + /** + * Removes a file and all its contents. + * + * @param {string} path Relative path to the file. + * @return {Promise} Promise to be resolved when the file is deleted. + */ + removeFile(path: string) : Promise { + return this.init().then(() => { + // Remove basePath if it's in the path. + path = this.removeStartingSlash(path.replace(this.basePath, '')); + this.logger.debug('Remove file: ' + path); + return this.file.removeFile(this.basePath, path); + }); + } + + /** + * Removes a file given its FileEntry. + * + * @param {FileEntry} fileEntry File Entry. + * @return {Promise} Promise resolved when the file is deleted. + */ + removeFileByFileEntry(fileEntry: any) : Promise { + return new Promise((resolve, reject) => { + fileEntry.remove(resolve, reject); + }); + } + + /** + * Retrieve the contents of a directory (not subdirectories). + * + * @param {string} path Relative path to the directory. + * @return {Promise} Promise to be resolved when the contents are retrieved. + */ + getDirectoryContents(path: string) : Promise { + return this.init().then(() => { + // Remove basePath if it's in the path. + path = this.removeStartingSlash(path.replace(this.basePath, '')); + this.logger.debug('Get contents of dir: ' + path); + + return this.file.listDir(this.basePath, path); + }); + } + + /** + * Calculate the size of a directory or a file. + * + * @param {any} entry Directory or file. + * @return {Promise} Promise to be resolved when the size is calculated. + */ + protected getSize(entry: any) : Promise { + return new Promise((resolve, reject) => { + if (entry.isDirectory) { + let directoryReader = entry.createReader(); + directoryReader.readEntries((entries) => { + + let promises = []; + for (let i = 0; i < entries.length; i++) { + promises.push(this.getSize(entries[i])); + } + + Promise.all(promises).then((sizes) => { + + let directorySize = 0; + for (let i = 0; i < sizes.length; i++) { + let fileSize = parseInt(sizes[i]); + if (isNaN(fileSize)) { + reject(); + return; + } + directorySize += fileSize; + } + resolve(directorySize); + + }, reject); + + }, reject); + + } else if (entry.isFile) { + entry.file((file) => { + resolve(file.size); + }, reject); + } + }); + } + + /** + * Calculate the size of a directory. + * + * @param {string} path Relative path to the directory. + * @return {Promise} Promise to be resolved when the size is calculated. + */ + getDirectorySize(path: string) : Promise { + // Remove basePath if it's in the path. + path = this.removeStartingSlash(path.replace(this.basePath, '')); + + this.logger.debug('Get size of dir: ' + path); + return this.getDir(path).then((dirEntry) => { + return this.getSize(dirEntry); + }); + } + + /** + * Calculate the size of a file. + * + * @param {string} path Relative path to the file. + * @return {Promise} Promise to be resolved when the size is calculated. + */ + getFileSize(path: string) : Promise { + // Remove basePath if it's in the path. + path = this.removeStartingSlash(path.replace(this.basePath, '')); + + this.logger.debug('Get size of file: ' + path); + return this.getFile(path).then((fileEntry) => { + return this.getSize(fileEntry); + }); + } + + /** + * Get file object from a FileEntry. + * + * @param {FileEntry} path Relative path to the file. + * @return {Promise} Promise to be resolved when the file is retrieved. + */ + getFileObjectFromFileEntry(entry: FileEntry) : Promise { + return new Promise((resolve, reject) => { + this.logger.debug('Get file object of: ' + entry.fullPath); + entry.file(resolve, reject); + }); + } + + /** + * Calculate the free space in the disk. + * + * @return {Promise} Promise resolved with the estimated free space in bytes. + */ + calculateFreeSpace() : Promise { + return this.file.getFreeDiskSpace().then((size) => { + return size; // GetFreeDiskSpace returns KB. + }); + } + + /** + * Normalize a filename that usually comes URL encoded. + * + * @param {string} filename The file name. + * @return {string} The file name normalized. + */ + normalizeFileName(filename: string) : string { + filename = this.textUtils.decodeURIComponent(filename); + return filename; + } + + /** + * Read a file from local file system. + * + * @param {string} path Relative path to the file. + * @param {number} [format=FORMATTEXT] Format to read the file. Must be one of: + * FORMATTEXT + * FORMATDATAURL + * FORMATBINARYSTRING + * FORMATARRAYBUFFER + * @return {Promise} Promise to be resolved when the file is read. + */ + readFile(path: string, format = this.FORMATTEXT) : Promise { + // Remove basePath if it's in the path. + path = this.removeStartingSlash(path.replace(this.basePath, '')); + this.logger.debug('Read file ' + path + ' with format ' + format); + + switch (format) { + case this.FORMATDATAURL: + return this.file.readAsDataURL(this.basePath, path); + case this.FORMATBINARYSTRING: + return this.file.readAsBinaryString(this.basePath, path); + case this.FORMATARRAYBUFFER: + return this.file.readAsArrayBuffer(this.basePath, path); + default: + return this.file.readAsText(this.basePath, path); + } + } + + /** + * Read file contents from a file data object. + * + * @param {any} fileData File's data. + * @param {number} [format=FORMATTEXT] Format to read the file. Must be one of: + * FORMATTEXT + * FORMATDATAURL + * FORMATBINARYSTRING + * FORMATARRAYBUFFER + * @return {Promise} Promise to be resolved when the file is read. + */ + readFileData(fileData: any, format = this.FORMATTEXT) : Promise { + format = format || this.FORMATTEXT; + this.logger.debug('Read file from file data with format ' + format); + + return new Promise((resolve, reject) => { + let reader = new FileReader(); + reader.onloadend = (evt) => { + let target = evt.target; // Convert to to be able to use non-standard properties. + if (target.result !== undefined || target.result !== null) { + resolve(target.result); + } else if (target.error !== undefined || target.error !== null) { + reject(target.error); + } else { + reject({code: null, message: 'READER_ONLOADEND_ERR'}); + } + } + + switch (format) { + case this.FORMATDATAURL: + reader.readAsDataURL(fileData); + break; + case this.FORMATBINARYSTRING: + reader.readAsBinaryString(fileData); + break; + case this.FORMATARRAYBUFFER: + reader.readAsArrayBuffer(fileData); + break; + default: + reader.readAsText(fileData); + } + + }); + } + + /** + * Writes some data in a file. + * + * @param {string} path Relative path to the file. + * @param {any} data Data to write. + * @return {Promise} Promise to be resolved when the file is written. + */ + writeFile(path: string, data: any) : Promise { + return this.init().then(() => { + // Remove basePath if it's in the path. + path = this.removeStartingSlash(path.replace(this.basePath, '')); + this.logger.debug('Write file: ' + path); + + // Create file (and parent folders) to prevent errors. + return this.createFile(path).then((fileEntry) => { + if (this.isHTMLAPI && this.appProvider.isDesktop() && + (typeof data == 'string' || data.toString() == '[object ArrayBuffer]')) { + // We need to write Blobs. + let type = this.mimeUtils.getMimeType(this.mimeUtils.getFileExtension(path)); + data = new Blob([data], {type: type || 'text/plain'}); + } + return this.file.writeFile(this.basePath, path, data, {replace: true}).then(() => { + return fileEntry; + }); + }); + }); + } + + /** + * Gets a file that might be outside the app's folder. + * + * @param {string} fullPath Absolute path to the file. + * @return {Promise} Promise to be resolved when the file is retrieved. + */ + getExternalFile(fullPath: string) : Promise { + return this.file.resolveLocalFilesystemUrl(fullPath).then((entry) => { + return entry; + }); + } + + /** + * Removes a file that might be outside the app's folder. + * + * @param {string} fullPath Absolute path to the file. + * @return {Promise} Promise to be resolved when the file is removed. + */ + removeExternalFile(fullPath: string) : Promise { + // removeFile(fullPath, '') does not work, we need to pass two valid parameters. + let directory = fullPath.substring(0, fullPath.lastIndexOf('/') ), + filename = fullPath.substr(fullPath.lastIndexOf('/') + 1); + return this.file.removeFile(directory, filename); + } + + /** + * Get the base path where the application files are stored. + * + * @return {Promise} Promise to be resolved when the base path is retrieved. + */ + getBasePath() : Promise { + return this.init().then(() => { + if (this.basePath.slice(-1) == '/') { + return this.basePath; + } else { + return this.basePath + '/'; + } + }); + } + + /** + * Get the base path where the application files are stored in the format to be used for downloads. + * iOS: Internal URL (cdvfile://). + * Others: basePath (file://) + * + * @return {Promise} Promise to be resolved when the base path is retrieved. + */ + getBasePathToDownload() : Promise { + return this.init().then(() => { + if (this.platform.is('ios')) { + // In iOS we want the internal URL (cdvfile://localhost/persistent/...). + return this.file.resolveDirectoryUrl(this.basePath).then((dirEntry) => { + return dirEntry.toInternalURL(); + }); + } else { + // In the other platforms we use the basePath as it is (file://...). + return this.basePath; + } + }); + } + + /** + * Get the base path where the application files are stored. Returns the value instantly, without waiting for it to be ready. + * + * @return {string} Base path. If the service hasn't been initialized it will return an invalid value. + */ + getBasePathInstant() : string { + if (!this.basePath) { + return this.basePath; + } else if (this.basePath.slice(-1) == '/') { + return this.basePath; + } else { + return this.basePath + '/'; + } + } + + /** + * Move a file. + * + * @param {string} [originalPath] Path to the file to move. + * @param {string} [newPath] New path of the file. + * @return {Promise} Promise resolved when the entry is moved. + */ + moveFile(originalPath: string, newPath: string) : Promise { + return this.init().then(() => { + // Remove basePath if it's in the paths. + originalPath = this.removeStartingSlash(originalPath.replace(this.basePath, '')); + newPath = this.removeStartingSlash(newPath.replace(this.basePath, '')); + + if (this.isHTMLAPI) { + // In Cordova API we need to calculate the longest matching path to make it work. + // this.file.moveFile('a/', 'b/c.ext', 'a/', 'b/d.ext') doesn't work. + // cordovaFile.moveFile('a/b/', 'c.ext', 'a/b/', 'd.ext') works. + let commonPath = this.basePath, + dirsA = originalPath.split('/'), + dirsB = newPath.split('/'); + + for (let i = 0; i < dirsA.length; i++) { + let dir = dirsA[i]; + if (dirsB[i] === dir) { + // Found a common folder, add it to common path and remove it from each specific path. + dir = dir + '/'; + commonPath = this.textUtils.concatenatePaths(commonPath, dir); + originalPath = originalPath.replace(dir, ''); + newPath = newPath.replace(dir, ''); + } else { + // Folder doesn't match, stop searching. + break; + } + } + + return this.file.moveFile(commonPath, originalPath, commonPath, newPath); + } else { + return this.file.moveFile(this.basePath, originalPath, this.basePath, newPath); + } + }); + } + + /** + * Copy a file. + * + * @param {string} from Path to the file to move. + * @param {string} to New path of the file. + * @return {Promise} Promise resolved when the entry is copied. + */ + copyFile(from: string, to: string) : Promise { + let fromFileAndDir, + toFileAndDir; + + return this.init().then(() => { + // Paths cannot start with "/". Remove basePath if present. + from = this.removeStartingSlash(from.replace(this.basePath, '')); + to = this.removeStartingSlash(to.replace(this.basePath, '')); + + fromFileAndDir = this.getFileAndDirectoryFromPath(from); + toFileAndDir = this.getFileAndDirectoryFromPath(to); + + if (toFileAndDir.directory) { + // Create the target directory if it doesn't exist. + return this.createDir(toFileAndDir.directory); + } + }).then(() => { + if (this.isHTMLAPI) { + // In HTML API, the file name cannot include a directory, otherwise it fails. + let fromDir = this.textUtils.concatenatePaths(this.basePath, fromFileAndDir.directory), + toDir = this.textUtils.concatenatePaths(this.basePath, toFileAndDir.directory); + + return this.file.copyFile(fromDir, fromFileAndDir.name, toDir, toFileAndDir.name); + } else { + return this.file.copyFile(this.basePath, from, this.basePath, to); + } + }); + } + + /** + * Extract the file name and directory from a given path. + * + * @param {string} path Path to be extracted. + * @return {any} Plain object containing the file name and directory. + * @description + * file.pdf -> directory: '', name: 'file.pdf' + * /file.pdf -> directory: '', name: 'file.pdf' + * path/file.pdf -> directory: 'path', name: 'file.pdf' + * path/ -> directory: 'path', name: '' + * path -> directory: '', name: 'path' + */ + getFileAndDirectoryFromPath(path: string) : any { + let file = { + directory: '', + name: '' + } + + file.directory = path.substring(0, path.lastIndexOf('/') ); + file.name = path.substr(path.lastIndexOf('/') + 1); + + return file; + } + + /** + * Get the internal URL of a file. + * + * @param {FileEntry} fileEntry File Entry. + * @return {string} Internal URL. + */ + getInternalURL(fileEntry: FileEntry) : string { + if (!fileEntry.toInternalURL) { + // File doesn't implement toInternalURL, use toURL. + return fileEntry.toURL(); + } + return fileEntry.toInternalURL(); + } + + /** + * Adds the basePath to a path if it doesn't have it already. + * + * @param {string} path Path to treat. + * @return {string} Path with basePath added. + */ + addBasePathIfNeeded(path: string) : string { + if (path.indexOf(this.basePath) > -1) { + return path; + } else { + return this.textUtils.concatenatePaths(this.basePath, path); + } + } + + /** + * Remove the base path from a path. If basePath isn't found, return false. + * + * @param {string} path Path to treat. + * @return {string} Path without basePath if basePath was found, undefined otherwise. + */ + removeBasePath(path: string) : string { + if (path.indexOf(this.basePath) > -1) { + return path.replace(this.basePath, ''); + } + } + + /** + * Unzips a file. + * + * @param {string} path Path to the ZIP file. + * @param {string} [destFolder] Path to the destination folder. If not defined, a new folder will be created with the + * same location and name as the ZIP file (without extension). + * @return {Promise} Promise resolved when the file is unzipped. + */ + unzipFile(path: string, destFolder?: string) : Promise { + // Get the source file. + return this.getFile(path).then((fileEntry) => { + // If destFolder is not set, use same location as ZIP file. We need to use absolute paths (including basePath). + destFolder = this.addBasePathIfNeeded(destFolder || this.mimeUtils.removeExtension(path)); + return this.zip.unzip(fileEntry.toURL(), destFolder); + }); + } + + /** + * Search a string or regexp in a file contents and replace it. The result is saved in the same file. + * + * @param {string} path Path to the file. + * @param {string|RegExp} search Value to search. + * @param {string} newValue New value. + * @return {Promise} Promise resolved in success. + */ + replaceInFile(path: string, search: string|RegExp, newValue: string) : Promise { + return this.readFile(path).then((content) => { + if (typeof content == 'undefined' || content === null || !content.replace) { + return Promise.reject(null); + } + + if (content.match(search)) { + content = content.replace(search, newValue); + return this.writeFile(path, content); + } + }); + } + + /** + * Get a file/dir metadata given the file's entry. + * + * @param {Entry} fileEntry FileEntry retrieved from getFile or similar. + * @return {Promise} Promise resolved with metadata. + */ + getMetadata(fileEntry: Entry) : Promise { + if (!fileEntry || !fileEntry.getMetadata) { + return Promise.reject(null); + } + + return new Promise((resolve, reject) => { + fileEntry.getMetadata(resolve, reject); + }); + } + + /** + * Get a file/dir metadata given the path. + * + * @param {string} path Path to the file/dir. + * @param {boolean} [isDir] True if directory, false if file. + * @return {Promise} Promise resolved with metadata. + */ + getMetadataFromPath(path: string, isDir?: boolean) : Promise { + let promise; + if (isDir) { + promise = this.getDir(path); + } else { + promise = this.getFile(path); + } + + return promise.then((entry) => { + return this.getMetadata(entry); + }); + } + + /** + * Remove the starting slash of a path if it's there. E.g. '/sites/filepool' -> 'sites/filepool'. + * + * @param {string} path Path. + * @return {string} Path without a slash in the first position. + */ + removeStartingSlash(path: string) : string { + if (path[0] == '/') { + return path.substr(1); + } + return path; + } + + /** + * Convenience function to copy or move an external file. + * + * @param {string} from Absolute path to the file to copy/move. + * @param {string} to Relative new path of the file (inside the app folder). + * @param {boolean} copy True to copy, false to move. + * @return {Promise} Promise resolved when the entry is copied/moved. + */ + protected copyOrMoveExternalFile(from: string, to: string, copy?: boolean) : Promise { + // Get the file to copy/move. + return this.getExternalFile(from).then((fileEntry) => { + // Create the destination dir if it doesn't exist. + let dirAndFile = this.getFileAndDirectoryFromPath(to); + return this.createDir(dirAndFile.directory).then((dirEntry) => { + // Now copy/move the file. + return new Promise((resolve, reject) => { + if (copy) { + fileEntry.copyTo(dirEntry, dirAndFile.name, resolve, reject); + } else { + fileEntry.moveTo(dirEntry, dirAndFile.name, resolve, reject); + } + }); + }); + }); + } + + /** + * Copy a file from outside of the app folder to somewhere inside the app folder. + * + * @param {string} from Absolute path to the file to copy. + * @param {string} to Relative new path of the file (inside the app folder). + * @return {Promise} Promise resolved when the entry is copied. + */ + copyExternalFile(from: string, to: string) : Promise { + return this.copyOrMoveExternalFile(from, to, true); + } + + /** + * Move a file from outside of the app folder to somewhere inside the app folder. + * + * @param {string} from Absolute path to the file to move. + * @param {string} to Relative new path of the file (inside the app folder). + * @return {Promise} Promise resolved when the entry is moved. + */ + moveExternalFile(from: string, to: string) : Promise { + return this.copyOrMoveExternalFile(from, to, false); + } + + /** + * Get a unique file name inside a folder, adding numbers to the file name if needed. + * + * @param {string} dirPath Path to the destination folder. + * @param {string} fileName File name that wants to be used. + * @param {string} [defaultExt] Default extension to use if no extension found in the file. + * @return {Promise} Promise resolved with the unique file name. + */ + getUniqueNameInFolder(dirPath: string, fileName: string, defaultExt: string) : Promise { + // Get existing files in the folder. + return this.getDirectoryContents(dirPath).then((entries) => { + let files = {}, + fileNameWithoutExtension = this.mimeUtils.removeExtension(fileName), + extension = this.mimeUtils.getFileExtension(fileName) || defaultExt, + newName, + number = 1; + + // Clean the file name. + fileNameWithoutExtension = this.textUtils.removeSpecialCharactersForFiles( + this.textUtils.decodeURIComponent(fileNameWithoutExtension)); + + // Index the files by name. + entries.forEach((entry) => { + files[entry.name] = entry; + }); + + // Format extension. + if (extension) { + extension = '.' + extension; + } else { + extension = ''; + } + + newName = fileNameWithoutExtension + extension; + if (typeof files[newName] == 'undefined') { + // No file with the same name. + return newName; + } else { + // Repeated name. Add a number until we find a free name. + do { + newName = fileNameWithoutExtension + '(' + number + ')' + extension; + number++; + } while (typeof files[newName] != 'undefined'); + + // Ask the user what he wants to do. + return newName; + } + }).catch(() => { + // Folder doesn't exist, name is unique. Clean it and return it. + return this.textUtils.removeSpecialCharactersForFiles(this.textUtils.decodeURIComponent(fileName)); + }); + } + + /** + * Remove app temporary folder. + * + * @return {Promise} Promise resolved when done. + */ + clearTmpFolder() : Promise { + return this.removeDir(this.TMPFOLDER); + } + + /** + * Given a folder path and a list of used files, remove all the files of the folder that aren't on the list of used files. + * + * @param {string} dirPath Folder path. + * @param {any[]} files List of used files. + * @return {Promise} Promise resolved when done, rejected if failure. + */ + removeUnusedFiles(dirPath: string, files: any[]) : Promise { + // Get the directory contents. + return this.getDirectoryContents(dirPath).then((contents) => { + if (!contents.length) { + return; + } + + let filesMap = {}, + promises = []; + + // Index the received files by fullPath and ignore the invalid ones. + files.forEach((file) => { + if (file.fullPath) { + filesMap[file.fullPath] = file; + } + }); + + // Check which of the content files aren't used anymore and delete them. + contents.forEach((file) => { + if (!filesMap[file.fullPath]) { + // File isn't used, delete it. + promises.push(this.removeFileByFileEntry(file)); + } + }); + + return Promise.all(promises); + }).catch(() => { + // Ignore errors, maybe it doesn't exist. + }); + } +}