// (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. }); } }