// (C) Copyright 2015 Moodle Pty Ltd. // // 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 { FileEntry, DirectoryEntry, Entry, Metadata, IFile } from '@ionic-native/file'; import { CoreApp } from '@services/app'; import { CoreWSExternalFile } from '@services/ws'; import { CoreMimetypeUtils } from '@services/utils/mimetype'; import { CoreTextUtils } from '@services/utils/text'; import { CoreUtils } from '@services/utils/utils'; import CoreConfigConstants from '@app/config.json'; import { CoreError } from '@classes/errors/error'; import { CoreLogger } from '@singletons/logger'; import { makeSingleton, File, Zip, Platform, WebView } from '@singletons/core.singletons'; /** * Progress event used when writing a file data into a file. */ export type CoreFileProgressEvent = { /** * Whether the values are reliabñe. */ lengthComputable?: boolean; /** * Number of treated bytes. */ loaded?: number; /** * Total of bytes. */ total?: number; }; /** * Progress function. */ export type CoreFileProgressFunction = (event: CoreFileProgressEvent) => void; /** * Constants to define the format to read a file. */ export const enum CoreFileFormat { FORMATTEXT = 0, FORMATDATAURL = 1, FORMATBINARYSTRING = 2, FORMATARRAYBUFFER = 3, FORMATJSON = 4, } /** * Factory to interact with the file system. */ @Injectable() export class CoreFileProvider { // Formats to read a file. static readonly FORMATTEXT = CoreFileFormat.FORMATTEXT; static readonly FORMATDATAURL = CoreFileFormat.FORMATDATAURL; static readonly FORMATBINARYSTRING = CoreFileFormat.FORMATBINARYSTRING; static readonly FORMATARRAYBUFFER = CoreFileFormat.FORMATARRAYBUFFER; static readonly FORMATJSON = CoreFileFormat.FORMATJSON; // Folders. static readonly SITESFOLDER = 'sites'; static readonly TMPFOLDER = 'tmp'; static readonly CHUNK_SIZE = 1048576; // 1 MB. Same chunk size as Ionic Native. protected logger: CoreLogger; protected initialized = false; protected basePath = ''; protected isHTMLAPI = false; constructor() { this.logger = CoreLogger.getInstance('CoreFileProvider'); // @todo: Check if redefining FileReader getters and setters is still needed in Android. } /** * Sets basePath to use with HTML API. Reserved for core use. * * @param path Base path to use. */ setHTMLBasePath(path: string): void { this.isHTMLAPI = true; this.basePath = path; } /** * Checks if we're using HTML API. * * @return 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 to be resolved when the initialization is finished. */ async init(): Promise { if (this.initialized) { return; } await Platform.instance.ready(); if (CoreApp.instance.isAndroid()) { this.basePath = File.instance.externalApplicationStorageDirectory || this.basePath; } else if (CoreApp.instance.isIOS()) { this.basePath = File.instance.documentsDirectory || this.basePath; } else if (!this.isAvailable() || this.basePath === '') { this.logger.error('Error getting device OS.'); return Promise.reject(new CoreError('Error getting device OS to initialize file system.')); } this.initialized = true; this.logger.debug('FS initialized: ' + this.basePath); } /** * Check if the plugin is available. * * @return Whether the plugin is available. */ isAvailable(): boolean { return typeof window.resolveLocalFileSystemURL !== 'undefined'; } /** * Get a file. * * @param path Relative path to the file. * @return Promise resolved when the file is retrieved. */ getFile(path: string): Promise { return this.init().then(() => { this.logger.debug('Get file: ' + path); return File.instance.resolveLocalFilesystemUrl(this.addBasePathIfNeeded(path)); }).then((entry) => entry); } /** * Get a directory. * * @param path Relative path to the directory. * @return Promise resolved when the directory is retrieved. */ getDir(path: string): Promise { return this.init().then(() => { this.logger.debug('Get directory: ' + path); return File.instance.resolveDirectoryUrl(this.addBasePathIfNeeded(path)); }); } /** * Get site folder path. * * @param siteId Site ID. * @return Site folder path. */ getSiteFolder(siteId: string): string { return CoreFileProvider.SITESFOLDER + '/' + siteId; } /** * Create a directory or a file. * * @param isDirectory True if a directory should be created, false if it should create a file. * @param path Relative path to the dir/file. * @param failIfExists True if it should fail if the dir/file exists, false otherwise. * @param base Base path to create the dir/file in. If not set, use basePath. * @return Promise to be resolved when the dir/file is created. */ protected async create( isDirectory: boolean, path: string, failIfExists?: boolean, base?: string, ): Promise { await this.init(); // 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 File.instance.createDir(base, path, !failIfExists); } else { this.logger.debug('Create file ' + path + ' in ' + base); return File.instance.createFile(base, path, !failIfExists); } } else { // The file plugin doesn't allow creating more than 1 level at a time (e.g. tmp/folder). // We need to create them 1 by 1. const firstDir = path.substr(0, path.indexOf('/')); const restOfPath = path.substr(path.indexOf('/') + 1); this.logger.debug('Create dir ' + firstDir + ' in ' + base); const newDirEntry = await File.instance.createDir(base, firstDir, true); return this.create(isDirectory, restOfPath, failIfExists, newDirEntry.toURL()); } } /** * Create a directory. * * @param path Relative path to the directory. * @param failIfExists True if it should fail if the directory exists, false otherwise. * @return Promise to be resolved when the directory is created. */ async createDir(path: string, failIfExists?: boolean): Promise { const entry = await this.create(true, path, failIfExists); return entry; } /** * Create a file. * * @param path Relative path to the file. * @param failIfExists True if it should fail if the file exists, false otherwise.. * @return Promise to be resolved when the file is created. */ async createFile(path: string, failIfExists?: boolean): Promise { const entry = await this.create(true, path, failIfExists); return entry; } /** * Removes a directory and all its contents. * * @param path Relative path to the directory. * @return Promise to be resolved when the directory is deleted. */ async removeDir(path: string): Promise { await this.init(); // Remove basePath if it's in the path. path = this.removeStartingSlash(path.replace(this.basePath, '')); this.logger.debug('Remove directory: ' + path); await File.instance.removeRecursively(this.basePath, path); } /** * Removes a file and all its contents. * * @param path Relative path to the file. * @return Promise to be resolved when the file is deleted. */ async removeFile(path: string): Promise { await this.init(); // Remove basePath if it's in the path. path = this.removeStartingSlash(path.replace(this.basePath, '')); this.logger.debug('Remove file: ' + path); try { await File.instance.removeFile(this.basePath, path); } catch (error) { // The delete can fail if the path has encoded characters. Try again if that's the case. const decodedPath = decodeURI(path); if (decodedPath != path) { await File.instance.removeFile(this.basePath, decodedPath); } else { throw error; } } } /** * Removes a file given its FileEntry. * * @param fileEntry File Entry. * @return Promise resolved when the file is deleted. */ removeFileByFileEntry(entry: Entry): Promise { return new Promise((resolve, reject) => entry.remove(resolve, reject)); } /** * Retrieve the contents of a directory (not subdirectories). * * @param path Relative path to the directory. * @return Promise to be resolved when the contents are retrieved. */ async getDirectoryContents(path: string): Promise<(FileEntry | DirectoryEntry)[]> { await this.init(); // Remove basePath if it's in the path. path = this.removeStartingSlash(path.replace(this.basePath, '')); this.logger.debug('Get contents of dir: ' + path); const result = await File.instance.listDir(this.basePath, path); return <(FileEntry | DirectoryEntry)[]> result; } /** * Type guard to check if the param is a DirectoryEntry. * * @param entry Param to check. * @return Whether the param is a DirectoryEntry. */ protected isDirectoryEntry(entry: FileEntry | DirectoryEntry): entry is DirectoryEntry { return entry.isDirectory === true; } /** * Calculate the size of a directory or a file. * * @param entry Directory or file. * @return Promise to be resolved when the size is calculated. */ protected getSize(entry: DirectoryEntry | FileEntry): Promise { return new Promise((resolve, reject) => { if (this.isDirectoryEntry(entry)) { const directoryReader = entry.createReader(); directoryReader.readEntries(async (entries: (DirectoryEntry | FileEntry)[]) => { const promises: Promise[] = []; for (let i = 0; i < entries.length; i++) { promises.push(this.getSize(entries[i])); } try { const sizes = await Promise.all(promises); let directorySize = 0; for (let i = 0; i < sizes.length; i++) { const fileSize = Number(sizes[i]); if (isNaN(fileSize)) { reject(); return; } directorySize += fileSize; } resolve(directorySize); } catch (error) { reject(error); } }, reject); } else { entry.file((file) => { resolve(file.size); }, reject); } }); } /** * Calculate the size of a directory. * * @param path Relative path to the directory. * @return 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) => this.getSize(dirEntry)); } /** * Calculate the size of a file. * * @param path Relative path to the file. * @return 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) => this.getSize(fileEntry)); } /** * Get file object from a FileEntry. * * @param path Relative path to the file. * @return Promise to be resolved when the file is retrieved. */ getFileObjectFromFileEntry(entry: FileEntry): Promise { return new Promise((resolve, reject): void => { this.logger.debug('Get file object of: ' + entry.fullPath); entry.file(resolve, reject); }); } /** * Calculate the free space in the disk. * Please notice that this function isn't reliable and it's not documented in the Cordova File plugin. * * @return Promise resolved with the estimated free space in bytes. */ calculateFreeSpace(): Promise { return File.instance.getFreeDiskSpace().then((size) => { if (CoreApp.instance.isIOS()) { // In iOS the size is in bytes. return Number(size); } // The size is in KB, convert it to bytes. return Number(size) * 1024; }); } /** * Normalize a filename that usually comes URL encoded. * * @param filename The file name. * @return The file name normalized. */ normalizeFileName(filename: string): string { filename = CoreTextUtils.instance.decodeURIComponent(filename); return filename; } /** * Read a file from local file system. * * @param path Relative path to the file. * @param format Format to read the file. * @return Promise to be resolved when the file is read. */ readFile(path: string, format: CoreFileFormat = CoreFileProvider.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 CoreFileProvider.FORMATDATAURL: return File.instance.readAsDataURL(this.basePath, path); case CoreFileProvider.FORMATBINARYSTRING: return File.instance.readAsBinaryString(this.basePath, path); case CoreFileProvider.FORMATARRAYBUFFER: return File.instance.readAsArrayBuffer(this.basePath, path); case CoreFileProvider.FORMATJSON: return File.instance.readAsText(this.basePath, path).then((text) => { const parsed = CoreTextUtils.instance.parseJSON(text, null); if (parsed == null && text != null) { throw new CoreError('Error parsing JSON file: ' + path); } return parsed; }); default: return File.instance.readAsText(this.basePath, path); } } /** * Read file contents from a file data object. * * @param fileData File's data. * @param format Format to read the file. * @return Promise to be resolved when the file is read. */ readFileData(fileData: IFile, format: CoreFileFormat = CoreFileProvider.FORMATTEXT): Promise { format = format || CoreFileProvider.FORMATTEXT; this.logger.debug('Read file from file data with format ' + format); return new Promise((resolve, reject): void => { const reader = new FileReader(); reader.onloadend = (event): void => { if (event.target?.result !== undefined && event.target.result !== null) { if (format == CoreFileProvider.FORMATJSON) { // Convert to object. const parsed = CoreTextUtils.instance.parseJSON( event.target.result, null); if (parsed == null) { reject('Error parsing JSON file.'); } resolve(parsed); } else { resolve(event.target.result); } } else if (event.target?.error !== undefined && event.target.error !== null) { reject(event.target.error); } else { reject({ code: null, message: 'READER_ONLOADEND_ERR' }); } }; // Check if the load starts. If it doesn't start in 3 seconds, reject. // Sometimes in Android the read doesn't start for some reason, so the promise never finishes. let hasStarted = false; reader.onloadstart = () => { hasStarted = true; }; setTimeout(() => { if (!hasStarted) { reject('Upload cannot start.'); } }, 3000); switch (format) { case CoreFileProvider.FORMATDATAURL: reader.readAsDataURL(fileData); break; case CoreFileProvider.FORMATBINARYSTRING: reader.readAsBinaryString(fileData); break; case CoreFileProvider.FORMATARRAYBUFFER: reader.readAsArrayBuffer(fileData); break; default: reader.readAsText(fileData); } }); } /** * Writes some data in a file. * * @param path Relative path to the file. * @param data Data to write. * @param append Whether to append the data to the end of the file. * @return Promise to be resolved when the file is written. */ async writeFile(path: string, data: string | Blob, append?: boolean): Promise { await this.init(); // 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. const fileEntry = await this.createFile(path); if (this.isHTMLAPI && !CoreApp.instance.isDesktop() && (typeof data == 'string' || data.toString() == '[object ArrayBuffer]')) { // We need to write Blobs. const extension = CoreMimetypeUtils.instance.getFileExtension(path); const type = extension ? CoreMimetypeUtils.instance.getMimeType(extension) : ''; data = new Blob([data], { type: type || 'text/plain' }); } await File.instance.writeFile(this.basePath, path, data, { replace: !append, append: !!append }); return fileEntry; } /** * Write some file data into a filesystem file. * It's done in chunks to prevent crashing the app for big files. * Please notice Ionic Native writeFile function already splits by chunks, but it doesn't have an onProgress function. * * @param file The data to write. * @param path Path where to store the data. * @param onProgress Function to call on progress. * @param offset Offset where to start reading from. * @param append Whether to append the data to the end of the file. * @return Promise resolved when done. */ async writeFileDataInFile( file: Blob, path: string, onProgress?: CoreFileProgressFunction, offset: number = 0, append?: boolean, ): Promise { offset = offset || 0; try { // Get the chunk to write. const chunk = file.slice(offset, Math.min(offset + CoreFileProvider.CHUNK_SIZE, file.size)); const fileEntry = await this.writeFile(path, chunk, append); offset += CoreFileProvider.CHUNK_SIZE; onProgress && onProgress({ lengthComputable: true, loaded: offset, total: file.size, }); if (offset >= file.size) { // Done, stop. return fileEntry; } // Read the next chunk. return this.writeFileDataInFile(file, path, onProgress, offset, true); } catch (error) { if (error && error.target && error.target.error) { // Error returned by the writer, throw the "real" error. throw error.target.error; } throw error; } } /** * Gets a file that might be outside the app's folder. * * @param fullPath Absolute path to the file. * @return Promise to be resolved when the file is retrieved. */ getExternalFile(fullPath: string): Promise { return File.instance.resolveLocalFilesystemUrl(fullPath).then((entry) => entry); } /** * Calculate the size of a file. * * @param path Absolute path to the file. * @return Promise to be resolved when the size is calculated. */ async getExternalFileSize(path: string): Promise { const fileEntry = await this.getExternalFile(path); return this.getSize(fileEntry); } /** * Removes a file that might be outside the app's folder. * * @param fullPath Absolute path to the file. * @return Promise to be resolved when the file is removed. */ async removeExternalFile(fullPath: string): Promise { const directory = fullPath.substring(0, fullPath.lastIndexOf('/')); const filename = fullPath.substr(fullPath.lastIndexOf('/') + 1); await File.instance.removeFile(directory, filename); } /** * Get the base path where the application files are stored. * * @return 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 to be resolved when the base path is retrieved. */ async getBasePathToDownload(): Promise { await this.init(); if (CoreApp.instance.isIOS()) { // In iOS we want the internal URL (cdvfile://localhost/persistent/...). const dirEntry = await File.instance.resolveDirectoryUrl(this.basePath); 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 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 dir. * * @param originalPath Path to the dir to move. * @param newPath New path of the dir. * @param destDirExists Set it to true if you know the directory where to put the dir exists. If false, the function will * try to create it (slower). * @return Promise resolved when the entry is moved. */ async moveDir(originalPath: string, newPath: string, destDirExists?: boolean): Promise { const entry = await this.copyOrMoveFileOrDir(originalPath, newPath, true, false, destDirExists); return entry; } /** * Move a file. * * @param originalPath Path to the file to move. * @param newPath New path of the file. * @param destDirExists Set it to true if you know the directory where to put the file exists. If false, the function will * try to create it (slower). * @return Promise resolved when the entry is moved. */ async moveFile(originalPath: string, newPath: string, destDirExists?: boolean): Promise { const entry = await this.copyOrMoveFileOrDir(originalPath, newPath, false, false, destDirExists); return entry; } /** * Copy a directory. * * @param from Path to the directory to move. * @param to New path of the directory. * @param destDirExists Set it to true if you know the directory where to put the dir exists. If false, the function will * try to create it (slower). * @return Promise resolved when the entry is copied. */ async copyDir(from: string, to: string, destDirExists?: boolean): Promise { const entry = await this.copyOrMoveFileOrDir(from, to, true, true, destDirExists); return entry; } /** * Copy a file. * * @param from Path to the file to move. * @param to New path of the file. * @param destDirExists Set it to true if you know the directory where to put the file exists. If false, the function will * try to create it (slower). * @return Promise resolved when the entry is copied. */ async copyFile(from: string, to: string, destDirExists?: boolean): Promise { const entry = await this.copyOrMoveFileOrDir(from, to, false, true, destDirExists); return entry; } /** * Copy or move a file or a directory. * * @param from Path to the file/dir to move. * @param to New path of the file/dir. * @param isDir Whether it's a dir or a file. * @param copy Whether to copy. If false, it will move the file. * @param destDirExists Set it to true if you know the directory where to put the file/dir exists. If false, the function will * try to create it (slower). * @return Promise resolved when the entry is copied. */ protected async copyOrMoveFileOrDir( from: string, to: string, isDir?: boolean, copy?: boolean, destDirExists?: boolean, ): Promise { const fileIsInAppFolder = this.isPathInAppFolder(from); if (!fileIsInAppFolder) { return this.copyOrMoveExternalFile(from, to, copy); } const moveCopyFn: MoveCopyFunction = copy ? (isDir ? File.instance.copyDir.bind(File.instance) : File.instance.copyFile.bind(File.instance)) : (isDir ? File.instance.moveDir.bind(File.instance) : File.instance.moveFile.bind(File.instance)); await this.init(); // Paths cannot start with "/". Remove basePath if present. from = this.removeStartingSlash(from.replace(this.basePath, '')); to = this.removeStartingSlash(to.replace(this.basePath, '')); const toFileAndDir = this.getFileAndDirectoryFromPath(to); if (toFileAndDir.directory && !destDirExists) { // Create the target directory if it doesn't exist. await this.createDir(toFileAndDir.directory); } try { const entry = await moveCopyFn(this.basePath, from, this.basePath, to); return entry; } catch (error) { // The copy can fail if the path has encoded characters. Try again if that's the case. const decodedFrom = decodeURI(from); const decodedTo = decodeURI(to); if (from != decodedFrom || to != decodedTo) { return moveCopyFn(this.basePath, decodedFrom, this.basePath, decodedTo); } else { return Promise.reject(error); } } } /** * Extract the file name and directory from a given path. * * @param path Path to be extracted. * @return 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): {directory: string; name: string} { const 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. * Please notice that with WKWebView these URLs no longer work in mobile. Use fileEntry.toURL() along with convertFileSrc. * * @param fileEntry File Entry. * @return 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 path Path to treat. * @return Path with basePath added. */ addBasePathIfNeeded(path: string): string { if (path.indexOf(this.basePath) > -1) { return path; } else { return CoreTextUtils.instance.concatenatePaths(this.basePath, path); } } /** * Remove the base path from a path. If basePath isn't found, return false. * * @param path Path to treat. * @return Path without basePath if basePath was found, undefined otherwise. */ removeBasePath(path: string): string { if (path.indexOf(this.basePath) > -1) { return path.replace(this.basePath, ''); } return path; } /** * Unzips a file. * * @param path Path to the ZIP file. * @param 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). * @param onProgress Function to call on progress. * @param recreateDir Delete the dest directory before unzipping. Defaults to true. * @return Promise resolved when the file is unzipped. */ async unzipFile( path: string, destFolder?: string, onProgress?: (progress: ProgressEvent) => void, recreateDir: boolean = true, ): Promise { // Get the source file. const fileEntry = await this.getFile(path); if (destFolder && recreateDir) { // Make sure the dest dir doesn't exist already. await CoreUtils.instance.ignoreErrors(this.removeDir(destFolder)); // Now create the dir, otherwise if any of the ancestor dirs doesn't exist the unzip would fail. await this.createDir(destFolder); } // If destFolder is not set, use same location as ZIP file. We need to use absolute paths (including basePath). destFolder = this.addBasePathIfNeeded(destFolder || CoreMimetypeUtils.instance.removeExtension(path)); const result = await Zip.instance.unzip(fileEntry.toURL(), destFolder, onProgress); if (result == -1) { throw new CoreError('Unzip failed.'); } } /** * Search a string or regexp in a file contents and replace it. The result is saved in the same file. * * @param path Path to the file. * @param search Value to search. * @param newValue New value. * @return Promise resolved in success. */ async replaceInFile(path: string, search: string | RegExp, newValue: string): Promise { let content = await this.readFile(path); if (typeof content == 'undefined' || content === null || !content.replace) { throw new CoreError(`Error reading file ${path}`); } if (content.match(search)) { content = content.replace(search, newValue); await this.writeFile(path, content); } } /** * Get a file/dir metadata given the file's entry. * * @param fileEntry FileEntry retrieved from getFile or similar. * @return Promise resolved with metadata. */ getMetadata(fileEntry: Entry): Promise { if (!fileEntry || !fileEntry.getMetadata) { return Promise.reject(new CoreError('Cannot get metadata from file entry.')); } return new Promise((resolve, reject): void => { fileEntry.getMetadata(resolve, reject); }); } /** * Get a file/dir metadata given the path. * * @param path Path to the file/dir. * @param isDir True if directory, false if file. * @return 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) => this.getMetadata(entry)); } /** * Remove the starting slash of a path if it's there. E.g. '/sites/filepool' -> 'sites/filepool'. * * @param path Path. * @return 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 from Absolute path to the file to copy/move. * @param to Relative new path of the file (inside the app folder). * @param copy True to copy, false to move. * @return Promise resolved when the entry is copied/moved. */ protected async copyOrMoveExternalFile(from: string, to: string, copy?: boolean): Promise { // Get the file to copy/move. const fileEntry = await this.getExternalFile(from); // Create the destination dir if it doesn't exist. const dirAndFile = this.getFileAndDirectoryFromPath(to); const dirEntry = await this.createDir(dirAndFile.directory); // Now copy/move the file. return new Promise((resolve, reject): void => { if (copy) { fileEntry.copyTo(dirEntry, dirAndFile.name, (entry: FileEntry) => resolve(entry), reject); } else { fileEntry.moveTo(dirEntry, dirAndFile.name, (entry: FileEntry) => resolve(entry), reject); } }); } /** * Copy a file from outside of the app folder to somewhere inside the app folder. * * @param from Absolute path to the file to copy. * @param to Relative new path of the file (inside the app folder). * @return 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 from Absolute path to the file to move. * @param to Relative new path of the file (inside the app folder). * @return 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 dirPath Path to the destination folder. * @param fileName File name that wants to be used. * @param defaultExt Default extension to use if no extension found in the file. * @return Promise resolved with the unique file name. */ async getUniqueNameInFolder(dirPath: string, fileName: string, defaultExt?: string): Promise { // Get existing files in the folder. try { const entries = await this.getDirectoryContents(dirPath); const files = {}; let num = 1; let fileNameWithoutExtension = CoreMimetypeUtils.instance.removeExtension(fileName); let extension = CoreMimetypeUtils.instance.getFileExtension(fileName) || defaultExt; // Clean the file name. fileNameWithoutExtension = CoreTextUtils.instance.removeSpecialCharactersForFiles( CoreTextUtils.instance.decodeURIComponent(fileNameWithoutExtension), ); // Index the files by name. entries.forEach((entry) => { files[entry.name.toLowerCase()] = entry; }); // Format extension. if (extension) { extension = '.' + extension; } else { extension = ''; } let newName = fileNameWithoutExtension + extension; if (typeof files[newName.toLowerCase()] == 'undefined') { // No file with the same name. return newName; } else { // Repeated name. Add a number until we find a free name. do { newName = fileNameWithoutExtension + '(' + num + ')' + extension; num++; } while (typeof files[newName.toLowerCase()] != 'undefined'); // Ask the user what he wants to do. return newName; } } catch (error) { // Folder doesn't exist, name is unique. Clean it and return it. return CoreTextUtils.instance.removeSpecialCharactersForFiles(CoreTextUtils.instance.decodeURIComponent(fileName)); } } /** * Remove app temporary folder. * * @return Promise resolved when done. */ async clearTmpFolder(): Promise { // Ignore errors because the folder might not exist. await CoreUtils.instance.ignoreErrors(this.removeDir(CoreFileProvider.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 dirPath Folder path. * @param files List of used files. * @return Promise resolved when done, rejected if failure. */ async removeUnusedFiles(dirPath: string, files: (CoreWSExternalFile | FileEntry)[]): Promise { // Get the directory contents. try { const contents = await this.getDirectoryContents(dirPath); if (!contents.length) { return; } const filesMap: {[fullPath: string]: FileEntry} = {}; const promises: Promise[] = []; // Index the received files by fullPath and ignore the invalid ones. files.forEach((file) => { if ('fullPath' in file) { 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)); } }); await Promise.all(promises); } catch (error) { // Ignore errors, maybe it doesn't exist. } } /** * Check if a file is inside the app's folder. * * @param path The absolute path of the file to check. * @return Whether the file is in the app's folder. */ isFileInAppFolder(path: string): boolean { return path.indexOf(this.basePath) != -1; } /** * Get the path to the www folder at runtime based on the WebView URL. * * @return Path. */ getWWWPath(): string { const position = window.location.href.indexOf('index.html'); if (position != -1) { return window.location.href.substr(0, position); } return window.location.href; } /** * Get the full path to the www folder. * * @return Path. */ getWWWAbsolutePath(): string { if (cordova && cordova.file && cordova.file.applicationDirectory) { return CoreTextUtils.instance.concatenatePaths(cordova.file.applicationDirectory, 'www'); } // Cannot use Cordova to get it, use the WebView URL. return this.getWWWPath(); } /** * Helper function to call Ionic WebView convertFileSrc only in the needed platforms. * This is needed to make files work with the Ionic WebView plugin. * * @param src Source to convert. * @return Converted src. */ convertFileSrc(src: string): string { return CoreApp.instance.isIOS() ? WebView.instance.convertFileSrc(src) : src; } /** * Undo the conversion of convertFileSrc. * * @param src Source to unconvert. * @return Unconverted src. */ unconvertFileSrc(src: string): string { if (!CoreApp.instance.isIOS()) { return src; } return src.replace(CoreConfigConstants.ioswebviewscheme + '://localhost/_app_file_', 'file://'); } /** * Check if a certain path is in the app's folder (basePath). * * @param path Path to check. * @return Whether it's in the app folder. */ protected isPathInAppFolder(path: string): boolean { return !path || !path.match(/^[a-z0-9]+:\/\//i) || path.indexOf(this.basePath) != -1; } } export class CoreFile extends makeSingleton(CoreFileProvider) {} type MoveCopyFunction = (path: string, dirName: string, newPath: string, newDirName: string) => Promise;