diff --git a/src/core/h5p/components/h5p-player/h5p-player.ts b/src/core/h5p/components/h5p-player/h5p-player.ts index e3eac156f..e7ad45ce6 100644 --- a/src/core/h5p/components/h5p-player/h5p-player.ts +++ b/src/core/h5p/components/h5p-player/h5p-player.ts @@ -148,6 +148,8 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { this.observer = this.eventsProvider.on(eventName, () => { this.calculateState(); }); + }).catch(() => { + // An error probably means the file cannot be downloaded or we cannot check it (offline). }); return; diff --git a/src/core/h5p/h5p.module.ts b/src/core/h5p/h5p.module.ts index f05f16c29..95874d33d 100644 --- a/src/core/h5p/h5p.module.ts +++ b/src/core/h5p/h5p.module.ts @@ -15,12 +15,14 @@ import { NgModule } from '@angular/core'; import { CoreH5PComponentsModule } from './components/components.module'; import { CoreH5PProvider } from './providers/h5p'; +import { CoreH5PUtilsProvider } from './providers/utils'; import { CoreH5PPluginFileHandler } from './providers/pluginfile-handler'; import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; // List of providers (without handlers). export const CORE_H5P_PROVIDERS: any[] = [ - CoreH5PProvider + CoreH5PProvider, + CoreH5PUtilsProvider ]; @NgModule({ @@ -30,6 +32,7 @@ export const CORE_H5P_PROVIDERS: any[] = [ ], providers: [ CoreH5PProvider, + CoreH5PUtilsProvider, CoreH5PPluginFileHandler ], exports: [] diff --git a/src/core/h5p/providers/h5p.ts b/src/core/h5p/providers/h5p.ts index 557d2c3c1..012689ff8 100644 --- a/src/core/h5p/providers/h5p.ts +++ b/src/core/h5p/providers/h5p.ts @@ -13,11 +13,15 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreFileProvider } from '@providers/file'; import { CoreLoggerProvider } from '@providers/logger'; -import { CoreSitesProvider } from '@providers/sites'; +import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreWSExternalWarning, CoreWSExternalFile } from '@providers/ws'; import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype'; +import { CoreH5PUtilsProvider } from './utils'; /** * Service to provide H5P functionalities. @@ -25,15 +29,179 @@ import { CoreTextUtilsProvider } from '@providers/utils/text'; @Injectable() export class CoreH5PProvider { + protected CONTENT_TABLE = 'h5p_content'; // H5P content. + protected LIBRARIES_TABLE = 'h5p_libraries'; // Installed libraries. + protected LIBRARY_DEPENDENCIES_TABLE = 'h5p_library_dependencies'; // Library dependencies. + + protected siteSchema: CoreSiteSchema = { + name: 'CoreH5PProvider', + version: 1, + tables: [ + { + name: this.CONTENT_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true + }, + { + name: 'jsoncontent', + type: 'TEXT', + notNull: true + }, + { + name: 'mainlibraryid', + type: 'INTEGER', + notNull: true + }, + { + name: 'displayoptions', + type: 'INTEGER' + }, + { + name: 'foldername', + type: 'TEXT', + notNull: true + }, + { + name: 'filtered', + type: 'TEXT' + }, + { + name: 'timecreated', + type: 'INTEGER', + notNull: true + }, + { + name: 'timemodified', + type: 'INTEGER', + notNull: true + } + ] + }, + { + name: this.LIBRARIES_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true + }, + { + name: 'machinename', + type: 'TEXT', + notNull: true + }, + { + name: 'title', + type: 'TEXT', + notNull: true + }, + { + name: 'majorversion', + type: 'INTEGER', + notNull: true + }, + { + name: 'minorversion', + type: 'INTEGER', + notNull: true + }, + { + name: 'patchversion', + type: 'INTEGER', + notNull: true + }, + { + name: 'runnable', + type: 'INTEGER', + notNull: true + }, + { + name: 'fullscreen', + type: 'INTEGER', + notNull: true + }, + { + name: 'embedtypes', + type: 'TEXT', + notNull: true + }, + { + name: 'preloadedjs', + type: 'TEXT' + }, + { + name: 'preloadedcss', + type: 'TEXT' + }, + { + name: 'droplibrarycss', + type: 'TEXT' + }, + { + name: 'semantics', + type: 'TEXT' + }, + { + name: 'addto', + type: 'TEXT' + } + ] + }, + { + name: this.LIBRARY_DEPENDENCIES_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true + }, + { + name: 'libraryid', + type: 'INTEGER', + notNull: true + }, + { + name: 'requiredlibraryid', + type: 'INTEGER', + notNull: true + }, + { + name: 'dependencytype', + type: 'TEXT', + notNull: true + } + ] + } + ] + }; + protected ROOT_CACHE_KEY = 'mmH5P:'; protected logger; constructor(logger: CoreLoggerProvider, + eventsProvider: CoreEventsProvider, private sitesProvider: CoreSitesProvider, - private textUtils: CoreTextUtilsProvider) { + private textUtils: CoreTextUtilsProvider, + private fileProvider: CoreFileProvider, + private mimeUtils: CoreMimetypeUtilsProvider, + private h5pUtils: CoreH5PUtilsProvider) { - this.logger = logger.getInstance('CoreFilterProvider'); + this.logger = logger.getInstance('CoreH5PProvider'); + + this.sitesProvider.registerSiteSchema(this.siteSchema); + + eventsProvider.on(CoreEventsProvider.SITE_STORAGE_DELETED, (data) => { + this.deleteAllData(data.siteId).catch((error) => { + this.logger.error('Error deleting all H5P data from site.', error); + }); + }); } /** @@ -54,7 +222,7 @@ export class CoreH5PProvider { * * @param site Site. If not defined, current site. * @return Promise resolved with true if ws is available, false otherwise. - * @since 3.4 + * @since 3.8 */ canGetTrustedH5PFileInSite(site?: CoreSite): boolean { site = site || this.sitesProvider.getCurrentSite(); @@ -62,6 +230,346 @@ export class CoreH5PProvider { return site.wsAvailable('core_h5p_get_trusted_h5p_file'); } + /** + * Will clear filtered params for all the content that uses the specified libraries. + * This means that the content dependencies will have to be rebuilt and the parameters re-filtered. + * + * @param libraryIds Array of library ids. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected clearFilteredParameters(libraryIds: number[], siteId?: string): Promise { + + if (!libraryIds || !libraryIds.length) { + return Promise.resolve(); + } + + return this.sitesProvider.getSiteDb(siteId).then((db) => { + const whereAndParams = db.getInOrEqual(libraryIds); + whereAndParams[0] = 'mainlibraryid ' + whereAndParams[0]; + + return db.updateRecordsWhere(this.CONTENT_TABLE, { filtered: null }, whereAndParams[0], whereAndParams[1]); + }); + } + + /** + * Delete all the H5P data from the DB of a certain site. + * + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected deleteAllData(siteId: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return Promise.all([ + db.deleteRecords(this.CONTENT_TABLE), + db.deleteRecords(this.LIBRARIES_TABLE), + db.deleteRecords(this.LIBRARY_DEPENDENCIES_TABLE) + ]); + }); + } + + /** + * Delete content data from DB. + * + * @param id Content ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + deleteContentData(id: number, siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.deleteRecords(this.CONTENT_TABLE, {id: id}); + }); + } + + /** + * Delete library data from DB. + * + * @param id Library ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + deleteLibraryData(id: number, siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.deleteRecords(this.LIBRARIES_TABLE, {id: id}); + }); + } + + /** + * Delete all dependencies belonging to given library. + * + * @param libraryId Library ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + deleteLibraryDependencies(libraryId: number, siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.deleteRecords(this.LIBRARY_DEPENDENCIES_TABLE, {libraryid: libraryId}); + }); + } + + /** + * Deletes a library from the file system. + * + * @param libraryData The library data. + * @param folderName Folder name. If not provided, it will be calculated. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + deleteLibraryFolder(libraryData: any, folderName?: string, siteId?: string): Promise { + return this.fileProvider.removeDir(this.getLibraryFolderPath(libraryData, siteId, folderName)); + } + + /** + * Extract an H5P file. Some of this code was copied from the isValidPackage function in Moodle's H5PValidator. + * This function won't validate most things because it should've been done by the server already. + * + * @param fileUrl The file URL used to download the file. + * @param file The file entry of the downloaded file. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + extractH5PFile(fileUrl: string, file: FileEntry, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Unzip the file. + const folderName = this.mimeUtils.removeExtension(file.name), + destFolder = this.textUtils.concatenatePaths(CoreFileProvider.TMPFOLDER, 'h5p/' + folderName); + + // Make sure the dest dir doesn't exist already. + return this.fileProvider.removeDir(destFolder).catch(() => { + // Ignore errors. + }).then(() => { + return this.fileProvider.createDir(destFolder); + }).then(() => { + return this.fileProvider.unzipFile(file.toURL(), destFolder); + }).then(() => { + // Read the contents of the unzipped dir. + return this.fileProvider.getDirectoryContents(destFolder); + }).then((contents) => { + return this.processH5PFiles(destFolder, contents).then((data) => { + const content: any = {}; + + // Save the libraries that were processed. + return this.saveLibraries(data.librariesJsonData, siteId).then(() => { + // Now treat contents. + + // Find main library version + for (const i in data.mainJsonData.preloadedDependencies) { + const dependency = data.mainJsonData.preloadedDependencies[i]; + + if (dependency.machineName === data.mainJsonData.mainLibrary) { + return this.getLibraryIdByData(dependency).then((id) => { + dependency.libraryId = id; + content.library = dependency; + }); + } + } + }).then(() => { + // Save the content data in DB. + content.params = JSON.stringify(data.contentJsonData); + + return this.saveContentData(content, folderName, siteId); + }).then(() => { + // Save the content files in their right place. + const contentPath = this.textUtils.concatenatePaths(destFolder, 'content'); + + return this.saveContentInFS(contentPath, folderName, siteId).catch((error) => { + // An error occurred, delete the DB data because the content data has been deleted. + return this.deleteContentData(content.id, siteId).catch(() => { + // Ignore errors. + }).then(() => { + return Promise.reject(error); + }); + }); + }).then(() => { + // Remove tmp folder. + return this.fileProvider.removeDir(destFolder).catch(() => { + // Ignore errors, it will be deleted eventually. + }); + }); + + // @todo: Load content? It's done in the player construct. + }); + }); + } + + /** + * Get a package content path. + * + * @param folderName Name of the folder of the H5P package. + * @param siteId The site ID. + * @return Folder path. + */ + getContentFolderPath(folderName: string, siteId: string): string { + return this.textUtils.concatenatePaths(this.fileProvider.getSiteFolder(siteId), 'h5p/packages/' + folderName + '/content'); + } + + /** + * Get library data. This code is based on the getLibraryData from Moodle's H5PValidator. + * This function won't validate most things because it should've been done by the server already. + * + * @param libDir Directory where the library files are. + * @param libPath Path to the directory where the library files are. + * @param h5pDir Path to the directory where this h5p files are. + * @return Library data. + */ + protected getLibraryData(libDir: DirectoryEntry, libPath: string, h5pDir: string): any { + const libraryJsonPath = this.textUtils.concatenatePaths(libPath, 'library.json'), + semanticsPath = this.textUtils.concatenatePaths(libPath, 'semantics.json'), + langPath = this.textUtils.concatenatePaths(libPath, 'language'), + iconPath = this.textUtils.concatenatePaths(libPath, 'icon.svg'), + promises = []; + let h5pData, + semanticsData, + langData, + hasIcon; + + // Read the library json file. + promises.push(this.fileProvider.readFile(libraryJsonPath, CoreFileProvider.FORMATJSON).then((data) => { + h5pData = data; + })); + + // Get library semantics if it exists. + promises.push(this.fileProvider.readFile(semanticsPath, CoreFileProvider.FORMATJSON).then((data) => { + semanticsData = data; + }).catch(() => { + // Probably doesn't exist, ignore. + })); + + // Get language data if it exists. + promises.push(this.fileProvider.getDirectoryContents(langPath).then((entries) => { + const subPromises = []; + langData = {}; + + entries.forEach((entry) => { + const langFilePath = this.textUtils.concatenatePaths(langPath, entry.name); + + subPromises.push(this.fileProvider.readFile(langFilePath, CoreFileProvider.FORMATJSON).then((data) => { + const parts = entry.name.split('.'); // The language code is in parts[0]. + langData[parts[0]] = data; + })); + }); + }).catch(() => { + // Probably doesn't exist, ignore. + })); + + // Check if it has icon. + promises.push(this.fileProvider.getFile(iconPath).then(() => { + hasIcon = true; + }).catch(() => { + hasIcon = false; + })); + + return Promise.all(promises).then(() => { + h5pData.semantics = semanticsData; + h5pData.language = langData; + h5pData.hasIcon = hasIcon; + + return h5pData; + }); + } + + /** + * Get a library data stored in DB. + * + * @param machineName Machine name. + * @param majorVersion Major version number. + * @param minorVersion Minor version number. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library data, rejected if not found. + */ + protected getLibrary(machineName: string, majorVersion?: string | number, minorVersion?: string | number, siteId?: string) + : Promise { + + return this.sitesProvider.getSiteDb(siteId).then((db) => { + const conditions: any = { + machinename: machineName + }; + + if (typeof majorVersion != 'undefined') { + conditions.majorversion = majorVersion; + } + if (typeof minorVersion != 'undefined') { + conditions.minorversion = minorVersion; + } + + return db.getRecords(this.LIBRARIES_TABLE, conditions); + }).then((libraries) => { + if (!libraries.length) { + return Promise.reject(null); + } + + return libraries[0]; + }); + } + + /** + * Get a library data stored in DB. + * + * @param libraryData Library data. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library data, rejected if not found. + */ + protected getLibraryByData(libraryData: any, siteId?: string): Promise { + return this.getLibrary(libraryData.machineName, libraryData.majorVersion, libraryData.minorVersion, siteId); + } + + /** + * Get a library ID. If not found, return null. + * + * @param machineName Machine name. + * @param majorVersion Major version number. + * @param minorVersion Minor version number. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library ID, null if not found. + */ + protected getLibraryId(machineName: string, majorVersion?: string | number, minorVersion?: string | number, siteId?: string) + : Promise { + + return this.getLibrary(machineName, majorVersion, minorVersion, siteId).then((library) => { + return (library && library.id) || null; + }).catch(() => { + return null; + }); + } + + /** + * Get a library ID. If not found, return null. + * + * @param libraryData Library data. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library ID, null if not found. + */ + protected getLibraryIdByData(libraryData: any, siteId?: string): Promise { + return this.getLibraryId(libraryData.machineName, libraryData.majorVersion, libraryData.minorVersion, siteId); + } + + /** + * Get libraries folder path. + * + * @param siteId The site ID. + * @return Folder path. + */ + getLibrariesFolderPath(siteId: string): string { + return this.textUtils.concatenatePaths(this.fileProvider.getSiteFolder(siteId), 'h5p/lib'); + } + + /** + * Get a library's folder path. + * + * @param libraryData The library data. + * @param siteId The site ID. + * @param folderName Folder name. If not provided, it will be calculated. + * @return Folder path. + */ + getLibraryFolderPath(libraryData: any, siteId: string, folderName?: string): string { + if (!folderName) { + folderName = this.libraryToString(libraryData, true); + } + + return this.textUtils.concatenatePaths(this.getLibrariesFolderPath(siteId), folderName); + } + /** * Get a trusted H5P file. * @@ -128,21 +636,6 @@ export class CoreH5PProvider { return this.ROOT_CACHE_KEY + 'trustedH5PFile:'; } - /** - * Treat an H5P url before sending it to WS. - * - * @param url H5P file URL. - * @param siteUrl Site URL. - * @return Treated url. - */ - protected treatH5PUrl(url: string, siteUrl: string): string { - if (url.indexOf(this.textUtils.concatenatePaths(siteUrl, '/webservice/pluginfile.php')) === 0) { - url = url.replace('/webservice/pluginfile', '/pluginfile'); - } - - return url; - } - /** * Invalidates all trusted H5P file WS calls. * @@ -167,6 +660,362 @@ export class CoreH5PProvider { return site.invalidateWsCacheForKey(this.getTrustedH5PFileCacheKey(url)); }); } + + /** + * Writes library data as string on the form {machineName} {majorVersion}.{minorVersion}. + * + * @param libraryData Library data. + * @param folderName Use hyphen instead of space in returned string. + * @return String on the form {machineName} {majorVersion}.{minorVersion}. + */ + protected libraryToString(libraryData: any, folderName?: boolean): string { + return (libraryData.machineName ? libraryData.machineName : libraryData.name) + (folderName ? '-' : ' ') + + libraryData.majorVersion + '.' + libraryData.minorVersion; + } + + /** + * Process libraries from an H5P library, getting the required data to save them. + * This code was copied from the isValidPackage function in Moodle's H5PValidator. + * This function won't validate most things because it should've been done by the server already. + * + * @param fileUrl The file URL used to download the file. + * @param file The file entry of the downloaded file. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected processH5PFiles(destFolder: string, entries: (DirectoryEntry | FileEntry)[]) + : Promise<{librariesJsonData: any, mainJsonData: any, contentJsonData: any}> { + + const promises = [], + libraries: any = {}; + let contentJsonData, + mainH5PData; + + // Read the h5p.json file. + const h5pJsonPath = this.textUtils.concatenatePaths(destFolder, 'h5p.json'); + promises.push(this.fileProvider.readFile(h5pJsonPath, CoreFileProvider.FORMATJSON).then((data) => { + mainH5PData = data; + })); + + // Read the content.json file. + const contentJsonPath = this.textUtils.concatenatePaths(destFolder, 'content/content.json'); + promises.push(this.fileProvider.readFile(contentJsonPath, CoreFileProvider.FORMATJSON).then((data) => { + contentJsonData = data; + })); + + // Treat libraries. + entries.forEach((entry) => { + if (entry.name[0] == '.' || entry.name[0] == '_' || entry.name == 'content' || entry.isFile) { + // Skip files, the content folder and any folder starting with a . or _. + return; + } + + const libDirPath = this.textUtils.concatenatePaths(destFolder, entry.name); + + promises.push(this.getLibraryData( entry, libDirPath, destFolder).then((libraryH5PData) => { + libraryH5PData.uploadDirectory = libDirPath; + libraries[this.libraryToString(libraryH5PData)] = libraryH5PData; + })); + }); + + return Promise.all(promises).then(() => { + return { + librariesJsonData: libraries, + mainJsonData: mainH5PData, + contentJsonData: contentJsonData + }; + }); + } + + /** + * Save content data in DB and clear cache. + * + * @param content Content to save. + * @param folderName The name of the folder that contains the H5P. + * @return Promise resolved with content ID. + */ + protected saveContentData(content: any, folderName: string, siteId?: string): Promise { + // Save in DB. + return this.sitesProvider.getSiteDb(siteId).then((db) => { + + const data: any = { + jsoncontent: content.params, + displayoptions: content.disable, + mainlibraryid: content.library.libraryId, + timemodified: Date.now(), + filtered: null, + foldername: folderName + }; + + if (typeof content.id != 'undefined') { + data.id = content.id; + } else { + data.timecreated = data.timemodified; + } + + return db.insertRecord(this.CONTENT_TABLE, data).then(() => { + if (!data.id) { + // New content. Get its ID. + return db.getRecord(this.CONTENT_TABLE, data).then((entry) => { + content.id = entry.id; + }); + } + }); + }).then(() => { + // If resetContentUserData is implemented in the future, it should be called in here. + return content.id; + }); + } + + /** + * Save the content in filesystem. + * + * @param contentPath Path to the current content folder (tmp). + * @param folderName Name to put to the content folder. + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected saveContentInFS(contentPath: string, folderName: string, siteId: string): Promise { + const folderPath = this.getContentFolderPath(folderName, siteId); + + // Delete existing content for this package. + return this.fileProvider.removeDir(folderPath).catch(() => { + // Ignore errors, maybe it doesn't exist. + }).then(() => { + // Copy the new one. + return this.fileProvider.moveDir(contentPath, folderPath); + }); + } + + /** + * Save libraries. This code is based on the saveLibraries function from Moodle's H5PStorage. + * + * @param librariesJsonData Data about libraries. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected saveLibraries(librariesJsonData: any, siteId?: string): Promise { + const libraryIds = []; + + // First of all, try to create the dir where the libraries are stored. This way we don't have to do it for each lib. + return this.fileProvider.createDir(this.getLibrariesFolderPath(siteId)).then(() => { + const promises = []; + + // Go through libraries that came with this package. + for (const libString in librariesJsonData) { + const libraryData = librariesJsonData[libString]; + + // Find local library identifier + promises.push(this.getLibraryByData(libraryData).catch(() => { + // Not found. + }).then((dbData) => { + if (dbData) { + // Library already installed. + libraryData.libraryId = dbData.id; + + if (libraryData.patchVersion <= dbData.patchversion) { + // Same or older version, no need to save. + libraryData.saveDependencies = false; + + return; + } + } + + libraryData.saveDependencies = true; + + // Convert metadataSettings values to boolean and json_encode it before saving. + libraryData.metadataSettings = libraryData.metadataSettings ? + this.h5pUtils.boolifyAndEncodeMetadataSettings(libraryData.metadataSettings) : null; + + // Save the library data in DB. + return this.saveLibraryData(libraryData, siteId).then(() => { + // Now save it in FS. + return this.saveLibraryInFS(libraryData, siteId).catch((error) => { + // An error occurred, delete the DB data because the lib FS data has been deleted. + return this.deleteLibraryData(libraryData.libraryId, siteId).catch(() => { + // Ignore errors. + }).then(() => { + return Promise.reject(error); + }); + }); + }).then(() => { + // @todo: Remove cached asses that use this library. + }); + })); + } + + return Promise.all(promises); + }).then(() => { + // Go through the libraries again to save dependencies. + const promises = []; + + for (const libString in librariesJsonData) { + const libraryData = librariesJsonData[libString]; + if (!libraryData.saveDependencies) { + continue; + } + + libraryIds.push(libraryData.libraryId); + + // Remove any old dependencies. + promises.push(this.deleteLibraryDependencies(libraryData.libraryId).then(() => { + // Insert the different new ones. + const subPromises = []; + + if (typeof libraryData.preloadedDependencies != 'undefined') { + subPromises.push(this.saveLibraryDependencies(libraryData.libraryId, libraryData.preloadedDependencies, + 'preloaded')); + } + if (typeof libraryData.dynamicDependencies != 'undefined') { + subPromises.push(this.saveLibraryDependencies(libraryData.libraryId, libraryData.dynamicDependencies, + 'dynamic')); + } + if (typeof libraryData.editorDependencies != 'undefined') { + subPromises.push(this.saveLibraryDependencies(libraryData.libraryId, libraryData.editorDependencies, + 'editor')); + } + + return Promise.all(subPromises); + })); + } + + return Promise.all(promises); + }).then(() => { + // Make sure dependencies, parameter filtering and export files get regenerated for content who uses these libraries. + if (libraryIds.length) { + return this.clearFilteredParameters(libraryIds, siteId); + } + }); + } + + /** + * Save a library in filesystem. + * + * @param libraryData Library data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected saveLibraryInFS(libraryData: any, siteId?: string): Promise { + const folderPath = this.getLibraryFolderPath(libraryData, siteId); + + // Delete existing library version. + return this.fileProvider.removeDir(folderPath).catch(() => { + // Ignore errors, maybe it doesn't exist. + }).then(() => { + // Copy the new one. + return this.fileProvider.moveDir(libraryData.uploadDirectory, folderPath, true); + }); + } + + /** + * Save library data in DB. + * + * @param libraryData Library data to save. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected saveLibraryData(libraryData: any, siteId?: string): Promise { + // Some special properties needs some checking and converting before they can be saved. + const preloadedJS = this.h5pUtils.libraryParameterValuesToCsv(libraryData, 'preloadedJs', 'path'), + preloadedCSS = this.h5pUtils.libraryParameterValuesToCsv(libraryData, 'preloadedCss', 'path'), + dropLibraryCSS = this.h5pUtils.libraryParameterValuesToCsv(libraryData, 'dropLibraryCss', 'machineName'); + + if (typeof libraryData.semantics == 'undefined') { + libraryData.semantics = ''; + } + if (typeof libraryData.fullscreen == 'undefined') { + libraryData.fullscreen = 0; + } + + let embedTypes = ''; + if (typeof libraryData.embedTypes != 'undefined') { + embedTypes = libraryData.embedTypes.join(', '); + } + + return this.sitesProvider.getSite(siteId).then((site) => { + const db = site.getDb(), + data: any = { + title: libraryData.title, + machinename: libraryData.machineName, + majorversion: libraryData.majorVersion, + minorversion: libraryData.minorVersion, + patchversion: libraryData.patchVersion, + runnable: libraryData.runnable, + fullscreen: libraryData.fullscreen, + embedtypes: embedTypes, + preloadedjs: preloadedJS, + preloadedcss: preloadedCSS, + droplibrarycss: dropLibraryCSS, + semantics: libraryData.semantics, + addto: typeof libraryData.addTo != 'undefined' ? JSON.stringify(libraryData.addTo) : null, + }; + + if (libraryData.libraryId) { + data.id = libraryData.libraryId; + } + + return db.insertRecord(this.LIBRARIES_TABLE, data).then(() => { + if (!data.id) { + // New library. Get its ID. + return db.getRecord(this.LIBRARIES_TABLE, data).then((entry) => { + libraryData.libraryId = entry.id; + }); + } else { + // Updated libary. Remove old dependencies. + return this.deleteLibraryDependencies(data.id, site.getId()); + } + }); + }); + } + + /** + * Save what libraries a library is depending on. + * + * @param libraryId Library Id for the library we're saving dependencies for. + * @param dependencies List of dependencies as associative arrays containing machineName, majorVersion, minorVersion. + * @param dependencytype The type of dependency. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected saveLibraryDependencies(libraryId: number, dependencies: any[], dependencyType: string, siteId?: string) + : Promise { + + return this.sitesProvider.getSiteDb(siteId).then((db) => { + + const promises = []; + + dependencies.forEach((dependency) => { + // Get the ID of the library. + promises.push(this.getLibraryIdByData(dependency, siteId).then((dependencyId) => { + // Create the relation. + const entry = { + libraryid: libraryId, + requiredlibraryid: dependencyId, + dependencytype: dependencyType + }; + + return db.insertRecord(this.LIBRARY_DEPENDENCIES_TABLE, entry); + })); + }); + + return Promise.all(promises); + }); + } + + /** + * Treat an H5P url before sending it to WS. + * + * @param url H5P file URL. + * @param siteUrl Site URL. + * @return Treated url. + */ + protected treatH5PUrl(url: string, siteUrl: string): string { + if (url.indexOf(this.textUtils.concatenatePaths(siteUrl, '/webservice/pluginfile.php')) === 0) { + url = url.replace('/webservice/pluginfile', '/pluginfile'); + } + + return url; + } } /** @@ -186,3 +1035,47 @@ export type CoreH5PGetTrustedH5PFileResult = { files: CoreWSExternalFile[]; // Files. warnings: CoreWSExternalWarning[]; // List of warnings. }; + +/** + * Content data stored in DB. + */ +export type CoreH5PContentDBData = { + id: number; // The id of the content. + jsoncontent: string; // The content in json format. + mainlibraryid: number; // The library we first instantiate for this node. + displayoptions: number; // H5P Button display options. + foldername: string; // Name of the folder that contains the contents. + filtered: string; // Filtered version of json_content. + timecreated: number; // Time created. + timemodified: number; // Time modified. +}; + +/** + * Library data stored in DB. + */ +export type CoreH5PLibraryDBData = { + id: number; // The id of the library. + machinename: string; // The library machine name. + title: string; // The human readable name of this library. + majorversion: number; // Major version. + minorversion: number; // Minor version. + patchversion: number; // Patch version. + runnable: number; // Can this library be started by the module? I.e. not a dependency. + fullscreen: number; // Display fullscreen button. + embedtypes: string; // List of supported embed types. + preloadedjs?: string; // Comma separated list of scripts to load. + preloadedcss?: string; // Comma separated list of stylesheets to load. + droplibrarycss?: string; // List of libraries that should not have CSS included if this library is used. Comma separated list. + semantics?: string; // The semantics definition in json format. + addto?: string; // Plugin configuration data. +}; + +/** + * Library dependencies stored in DB. + */ +export type CoreH5PLibraryDependenciesDBData = { + id: number; // Id. + libraryid: number; // The id of an H5P library. + requiredlibraryid: number; // The dependent library to load. + dependencytype: string; // Type: preloaded, dynamic, or editor. +}; diff --git a/src/core/h5p/providers/pluginfile-handler.ts b/src/core/h5p/providers/pluginfile-handler.ts index b61de5b00..a7787a3f0 100644 --- a/src/core/h5p/providers/pluginfile-handler.ts +++ b/src/core/h5p/providers/pluginfile-handler.ts @@ -108,14 +108,6 @@ export class CoreH5PPluginFileHandler implements CorePluginFileHandler { * @return Promise resolved when done. */ treatDownloadedFile(fileUrl: string, file: FileEntry, siteId?: string): Promise { - // Unzip the file. - const destFolder = this.textUtils.concatenatePaths(CoreFileProvider.TMPFOLDER, - 'h5p/' + this.mimeUtils.removeExtension(file.name)); - - return this.fileProvider.createDir(destFolder).then(() => { - return this.fileProvider.unzipFile(file.toURL(), destFolder); - }).then(() => { - // @todo: Deploy the package. - }); + return this.h5pProvider.extractH5PFile(fileUrl, file, siteId); } } diff --git a/src/core/h5p/providers/utils.ts b/src/core/h5p/providers/utils.ts new file mode 100644 index 000000000..3a36071cb --- /dev/null +++ b/src/core/h5p/providers/utils.ts @@ -0,0 +1,71 @@ +// (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'; + +/** + * Utils service with helper functions for H5P. + */ +@Injectable() +export class CoreH5PUtilsProvider { + + constructor() { + // Nothing to do. + } + + /** + * The metadataSettings field in libraryJson uses 1 for true and 0 for false. + * Here we are converting these to booleans, and also doing JSON encoding. + * + * @param metadataSettings Settings. + * @return Stringified settings. + */ + boolifyAndEncodeMetadataSettings(metadataSettings: any): string { + // Convert metadataSettings values to boolean. + if (typeof metadataSettings.disable != 'undefined') { + metadataSettings.disable = metadataSettings.disable === 1; + } + if (typeof metadataSettings.disableExtraTitleField != 'undefined') { + metadataSettings.disableExtraTitleField = metadataSettings.disableExtraTitleField === 1; + } + + return JSON.stringify(metadataSettings); + } + + /** + * Convert list of library parameter values to csv. + * + * @param libraryData Library data as found in library.json files. + * @param key Key that should be found in libraryData. + * @param searchParam The library parameter (Default: 'path'). + * @return Library parameter values separated by ', ' + */ + libraryParameterValuesToCsv(libraryData: any, key: string, searchParam: string = 'path'): string { + if (typeof libraryData[key] != 'undefined') { + const parameterValues = []; + + libraryData[key].forEach((file) => { + for (const index in file) { + if (index === searchParam) { + parameterValues.push(file[index]); + } + } + }); + + return parameterValues.join(','); + } + + return ''; + } +} diff --git a/src/providers/file.ts b/src/providers/file.ts index 32dc56bc4..dd48837c3 100644 --- a/src/providers/file.ts +++ b/src/providers/file.ts @@ -52,6 +52,7 @@ export class CoreFileProvider { static FORMATDATAURL = 1; static FORMATBINARYSTRING = 2; static FORMATARRAYBUFFER = 3; + static FORMATJSON = 4; // Folders. static SITESFOLDER = 'sites'; @@ -491,6 +492,7 @@ export class CoreFileProvider { * FORMATDATAURL * FORMATBINARYSTRING * FORMATARRAYBUFFER + * FORMATJSON * @return Promise to be resolved when the file is read. */ readFile(path: string, format: number = CoreFileProvider.FORMATTEXT): Promise { @@ -505,6 +507,16 @@ export class CoreFileProvider { return this.file.readAsBinaryString(this.basePath, path); case CoreFileProvider.FORMATARRAYBUFFER: return this.file.readAsArrayBuffer(this.basePath, path); + case CoreFileProvider.FORMATJSON: + return this.file.readAsText(this.basePath, path).then((text) => { + const parsed = this.textUtils.parseJSON(text, null); + + if (parsed == null && text != null) { + return Promise.reject('Error parsing JSON file: ' + path); + } + + return parsed; + }); default: return this.file.readAsText(this.basePath, path); } @@ -519,6 +531,7 @@ export class CoreFileProvider { * FORMATDATAURL * FORMATBINARYSTRING * FORMATARRAYBUFFER + * FORMATJSON * @return Promise to be resolved when the file is read. */ readFileData(fileData: any, format: number = CoreFileProvider.FORMATTEXT): Promise { @@ -531,7 +544,18 @@ export class CoreFileProvider { reader.onloadend = (evt): void => { const target = evt.target; // Convert to to be able to use non-standard properties. if (target.result !== undefined || target.result !== null) { - resolve(target.result); + if (format == CoreFileProvider.FORMATJSON) { + // Convert to object. + const parsed = this.textUtils.parseJSON(target.result, null); + + if (parsed == null) { + reject('Error parsing JSON file.'); + } + + resolve(parsed); + } else { + resolve(target.result); + } } else if (target.error !== undefined || target.error !== null) { reject(target.error); } else { @@ -728,19 +752,58 @@ export class CoreFileProvider { } } + /** + * 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. + */ + moveDir(originalPath: string, newPath: string, destDirExists?: boolean): Promise { + return this.moveFileOrDir(originalPath, newPath, true); + } + /** * 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. */ - moveFile(originalPath: string, newPath: string): Promise { + moveFile(originalPath: string, newPath: string, destDirExists?: boolean): Promise { + return this.moveFileOrDir(originalPath, newPath, false); + } + + /** + * Move a file/dir. + * + * @param originalPath Path to the file/dir to move. + * @param newPath New path of the file/dir. + * @param isDir Whether it's a dir or a 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 moved. + */ + protected moveFileOrDir(originalPath: string, newPath: string, isDir?: boolean, destDirExists?: boolean): Promise { + const moveFn = isDir ? this.file.moveDir.bind(this.file) : this.file.moveFile.bind(this.file); + 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, '')); + const newPathFileAndDir = this.getFileAndDirectoryFromPath(newPath); + + if (newPathFileAndDir.directory && !destDirExists) { + // Create the target directory if it doesn't exist. + return this.createDir(newPathFileAndDir.directory); + } + }).then(() => { + if (this.isHTMLAPI) { // In Cordova API we need to calculate the longest matching path to make it work. // The function this.file.moveFile('a/', 'b/c.ext', 'a/', 'b/d.ext') doesn't work. @@ -763,15 +826,15 @@ export class CoreFileProvider { } } - return this.file.moveFile(commonPath, originalPath, commonPath, newPath); + return moveFn(commonPath, originalPath, commonPath, newPath); } else { - return this.file.moveFile(this.basePath, originalPath, this.basePath, newPath).catch((error) => { + return moveFn(this.basePath, originalPath, this.basePath, newPath).catch((error) => { // The move can fail if the path has encoded characters. Try again if that's the case. const decodedOriginal = decodeURI(originalPath), decodedNew = decodeURI(newPath); if (decodedOriginal != originalPath || decodedNew != newPath) { - return this.file.moveFile(this.basePath, decodedOriginal, this.basePath, decodedNew); + return moveFn(this.basePath, decodedOriginal, this.basePath, decodedNew); } else { return Promise.reject(error); } @@ -780,16 +843,46 @@ export class CoreFileProvider { }); } + /** + * 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. + */ + copyDir(from: string, to: string, destDirExists?: boolean): Promise { + return this.copyFileOrDir(from, to, true, destDirExists); + } + /** * 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. */ - copyFile(from: string, to: string): Promise { + copyFile(from: string, to: string, destDirExists?: boolean): Promise { + return this.copyFileOrDir(from, to, false, destDirExists); + } + + /** + * Copy 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 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 copyFileOrDir(from: string, to: string, isDir?: boolean, destDirExists?: boolean): Promise { let fromFileAndDir, toFileAndDir; + const copyFn = isDir ? this.file.copyDir.bind(this.file) : this.file.copyFile.bind(this.file); return this.init().then(() => { // Paths cannot start with "/". Remove basePath if present. @@ -799,7 +892,7 @@ export class CoreFileProvider { fromFileAndDir = this.getFileAndDirectoryFromPath(from); toFileAndDir = this.getFileAndDirectoryFromPath(to); - if (toFileAndDir.directory) { + if (toFileAndDir.directory && !destDirExists) { // Create the target directory if it doesn't exist. return this.createDir(toFileAndDir.directory); } @@ -809,15 +902,15 @@ export class CoreFileProvider { const 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); + return copyFn(fromDir, fromFileAndDir.name, toDir, toFileAndDir.name); } else { - return this.file.copyFile(this.basePath, from, this.basePath, to).catch((error) => { + return copyFn(this.basePath, from, this.basePath, to).catch((error) => { // The copy can fail if the path has encoded characters. Try again if that's the case. const decodedFrom = decodeURI(from), decodedTo = decodeURI(to); if (from != decodedFrom || to != decodedTo) { - return this.file.copyFile(this.basePath, decodedFrom, this.basePath, decodedTo); + return copyFn(this.basePath, decodedFrom, this.basePath, decodedTo); } else { return Promise.reject(error); }