diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index e3d599345..0b7468465 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -1550,7 +1550,73 @@ "core.group": "Group", "core.groupsseparate": "Separate groups", "core.groupsvisible": "Visible groups", + "core.h5p.author": "Author", + "core.h5p.by": "by", + "core.h5p.cancellabel": "Cancel", + "core.h5p.ccattribution": "Attribution (CC BY)", + "core.h5p.ccattributionnc": "Attribution-NonCommercial (CC BY-NC)", + "core.h5p.ccattributionncnd": "Attribution-NonCommercial-NoDerivs (CC BY-NC-ND)", + "core.h5p.ccattributionncsa": "Attribution-NonCommercial-ShareAlike (CC BY-NC-SA)", + "core.h5p.ccattributionnd": "Attribution-NoDerivs (CC BY-ND)", + "core.h5p.ccattributionsa": "Attribution-ShareAlike (CC BY-SA)", + "core.h5p.changelog": "Changelog", + "core.h5p.close": "Close", + "core.h5p.confirmdialogbody": "Please confirm that you wish to proceed. This action is not reversible.", + "core.h5p.confirmdialogheader": "Confirm action", + "core.h5p.confirmlabel": "Confirm", + "core.h5p.connectionLost": "Connection lost. Results will be stored and sent when you regain connection.", + "core.h5p.connectionReestablished": "Connection reestablished.", + "core.h5p.contentCopied": "Content is copied to the clipboard", + "core.h5p.contentchanged": "This content has changed since you last used it.", + "core.h5p.contenttype": "Content Type", + "core.h5p.copyright": "Rights of use", + "core.h5p.copyrightstring": "Copyright", + "core.h5p.copyrighttitle": "View copyright information for this content.", + "core.h5p.disablefullscreen": "Disable fullscreen", + "core.h5p.download": "Download", + "core.h5p.downloadtitle": "Download this content as a H5P file.", + "core.h5p.embed": "Embed", + "core.h5p.embedtitle": "View the embed code for this content.", + "core.h5p.fullscreen": "Fullscreen", + "core.h5p.h5ptitle": "Visit H5P.org to check out more cool content.", + "core.h5p.hideadvanced": "Hide advanced", + "core.h5p.license": "License", + "core.h5p.licenseCC010": "CC0 1.0 Universal (CC0 1.0) Public Domain Dedication", + "core.h5p.licenseCC10": "1.0 Generic", + "core.h5p.licenseCC20": "2.0 Generic", + "core.h5p.licenseCC25": "2.5 Generic", + "core.h5p.licenseCC30": "3.0 Unported", + "core.h5p.licenseCC40": "4.0 International", + "core.h5p.licenseGPL": "General Public License", + "core.h5p.licenseV1": "Version 1", + "core.h5p.licenseV2": "Version 2", + "core.h5p.licenseV3": "Version 3", + "core.h5p.licenseextras": "License Extras", + "core.h5p.nocopyright": "No copyright information available for this content.", + "core.h5p.offlineDialogBody": "We were unable to send information about your completion of this task. Please check your internet connection.", + "core.h5p.offlineDialogHeader": "Your connection to the server was lost", + "core.h5p.offlineDialogRetryButtonLabel": "Retry now", + "core.h5p.offlineDialogRetryMessage": "Retrying in :num....", + "core.h5p.offlineSuccessfulSubmit": "Successfully submitted results.", + "core.h5p.pd": "Public Domain", + "core.h5p.pdm": "Public Domain Mark (PDM)", "core.h5p.play": "Play H5P", + "core.h5p.resizescript": "Include this script on your website if you want dynamic sizing of the embedded content:", + "core.h5p.resubmitScores": "Attempting to submit stored results.", + "core.h5p.reuse": "Reuse", + "core.h5p.reuseContent": "Reuse Content", + "core.h5p.reuseDescription": "Reuse this content.", + "core.h5p.showadvanced": "Show advanced", + "core.h5p.showless": "Show less", + "core.h5p.showmore": "Show more", + "core.h5p.size": "Size", + "core.h5p.source": "Source", + "core.h5p.startingover": "You'll be starting over.", + "core.h5p.sublevel": "Sublevel", + "core.h5p.thumbnail": "Thumbnail", + "core.h5p.title": "Title", + "core.h5p.undisclosed": "Undisclosed", + "core.h5p.year": "Year", "core.hasdatatosync": "This {{$a}} has offline data to be synchronised.", "core.help": "Help", "core.hide": "Hide", diff --git a/src/core/h5p/components/h5p-player/h5p-player.ts b/src/core/h5p/components/h5p-player/h5p-player.ts index e7ad45ce6..0627e89d6 100644 --- a/src/core/h5p/components/h5p-player/h5p-player.ts +++ b/src/core/h5p/components/h5p-player/h5p-player.ts @@ -16,6 +16,7 @@ import { Component, Input, ElementRef, OnInit, OnDestroy, OnChanges, SimpleChang import { CoreAppProvider } from '@providers/app'; import { CoreEventsProvider } from '@providers/events'; import { CoreFilepoolProvider } from '@providers/filepool'; +import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; @@ -23,6 +24,7 @@ import { CoreUrlUtilsProvider } from '@providers/utils/url'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreH5PProvider } from '@core/h5p/providers/h5p'; import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; +import { CoreConstants } from '@core/constants'; /** * Component to render an H5P package. @@ -47,8 +49,10 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { protected siteCanDownload: boolean; protected observer; protected urlParams; + protected logger; - constructor(public elementRef: ElementRef, + constructor(loggerProvider: CoreLoggerProvider, + public elementRef: ElementRef, protected sitesProvider: CoreSitesProvider, protected urlUtils: CoreUrlUtilsProvider, protected utils: CoreUtilsProvider, @@ -60,6 +64,7 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { protected domUtils: CoreDomUtilsProvider, protected pluginFileDelegate: CorePluginFileDelegate) { + this.logger = loggerProvider.getInstance('CoreH5PPlayerComponent'); this.siteId = sitesProvider.getCurrentSiteId(); this.siteCanDownload = this.sitesProvider.getCurrentSite().canDownloadFiles(); } @@ -92,11 +97,30 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { this.loading = true; - // @TODO: Check if package is downloaded and use the local player if so. + let promise; - // Get auto-login URL so the user is automatically authenticated. - this.sitesProvider.getCurrentSite().getAutoLoginUrl(this.src, false).then((url) => { - this.playerSrc = url; + if (this.canDownload && (this.state == CoreConstants.DOWNLOADED || this.state == CoreConstants.OUTDATED)) { + // Package is downloaded, use the local URL. + promise = this.h5pProvider.getContentIndexFileUrl(this.urlParams.url).catch((error) => { + // It seems there was something wrong when creating the index file. Delete the package? + this.logger.error('Error loading downloaded index:', error, this.src); + }); + } else { + promise = Promise.resolve(); + } + + promise.then((url) => { + if (url) { + // Local package. + this.playerSrc = url; + } else { + // Get auto-login URL so the user is automatically authenticated. + return this.sitesProvider.getCurrentSite().getAutoLoginUrl(this.src, false).then((url) => { + // Add the preventredirect param so the user can authenticate. + this.playerSrc = this.urlUtils.addParamsToUrl(url, {preventredirect: false}); + }); + } + }).finally(() => { this.loading = false; this.showPackage = true; }); diff --git a/src/core/h5p/lang/en.json b/src/core/h5p/lang/en.json index 954fa438a..0cffba19a 100644 --- a/src/core/h5p/lang/en.json +++ b/src/core/h5p/lang/en.json @@ -1,3 +1,69 @@ { - "play": "Play H5P" -} \ No newline at end of file + "author": "Author", + "by": "by", + "cancellabel": "Cancel", + "ccattribution": "Attribution (CC BY)", + "ccattributionnc": "Attribution-NonCommercial (CC BY-NC)", + "ccattributionncnd": "Attribution-NonCommercial-NoDerivs (CC BY-NC-ND)", + "ccattributionncsa": "Attribution-NonCommercial-ShareAlike (CC BY-NC-SA)", + "ccattributionnd": "Attribution-NoDerivs (CC BY-ND)", + "ccattributionsa": "Attribution-ShareAlike (CC BY-SA)", + "changelog": "Changelog", + "close": "Close", + "confirmdialogbody": "Please confirm that you wish to proceed. This action is not reversible.", + "confirmdialogheader": "Confirm action", + "confirmlabel": "Confirm", + "connectionLost": "Connection lost. Results will be stored and sent when you regain connection.", + "connectionReestablished": "Connection reestablished.", + "contentCopied": "Content is copied to the clipboard", + "contentchanged": "This content has changed since you last used it.", + "contenttype": "Content Type", + "copyright": "Rights of use", + "copyrightstring": "Copyright", + "copyrighttitle": "View copyright information for this content.", + "disablefullscreen": "Disable fullscreen", + "download": "Download", + "downloadtitle": "Download this content as a H5P file.", + "embed": "Embed", + "embedtitle": "View the embed code for this content.", + "fullscreen": "Fullscreen", + "h5ptitle": "Visit H5P.org to check out more cool content.", + "hideadvanced": "Hide advanced", + "license": "License", + "licenseCC010": "CC0 1.0 Universal (CC0 1.0) Public Domain Dedication", + "licenseCC10": "1.0 Generic", + "licenseCC20": "2.0 Generic", + "licenseCC25": "2.5 Generic", + "licenseCC30": "3.0 Unported", + "licenseCC40": "4.0 International", + "licenseGPL": "General Public License", + "licenseV1": "Version 1", + "licenseV2": "Version 2", + "licenseV3": "Version 3", + "licenseextras": "License Extras", + "nocopyright": "No copyright information available for this content.", + "offlineDialogBody": "We were unable to send information about your completion of this task. Please check your internet connection.", + "offlineDialogHeader": "Your connection to the server was lost", + "offlineDialogRetryButtonLabel": "Retry now", + "offlineDialogRetryMessage": "Retrying in :num....", + "offlineSuccessfulSubmit": "Successfully submitted results.", + "pd": "Public Domain", + "pdm": "Public Domain Mark (PDM)", + "play": "Play H5P", + "resizescript": "Include this script on your website if you want dynamic sizing of the embedded content:", + "resubmitScores": "Attempting to submit stored results.", + "reuse": "Reuse", + "reuseContent": "Reuse Content", + "reuseDescription": "Reuse this content.", + "showadvanced": "Show advanced", + "showless": "Show less", + "showmore": "Show more", + "size": "Size", + "source": "Source", + "startingover": "You'll be starting over.", + "sublevel": "Sublevel", + "thumbnail": "Thumbnail", + "title": "Title", + "undisclosed": "Undisclosed", + "year": "Year" +} diff --git a/src/core/h5p/providers/h5p.ts b/src/core/h5p/providers/h5p.ts index 012689ff8..57d674897 100644 --- a/src/core/h5p/providers/h5p.ts +++ b/src/core/h5p/providers/h5p.ts @@ -15,12 +15,15 @@ import { Injectable } from '@angular/core'; import { CoreEventsProvider } from '@providers/events'; import { CoreFileProvider } from '@providers/file'; +import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreLoggerProvider } from '@providers/logger'; 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 { CoreUrlUtilsProvider } from '@providers/utils/url'; +import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreH5PUtilsProvider } from './utils'; /** @@ -29,9 +32,48 @@ import { CoreH5PUtilsProvider } from './utils'; @Injectable() export class CoreH5PProvider { + static STYLES = [ + 'styles/h5p.css', + 'styles/h5p-confirmation-dialog.css', + 'styles/h5p-core-button.css' + ]; + static SCRIPTS = [ + 'js/jquery.js', + 'js/h5p.js', + 'js/h5p-event-dispatcher.js', + 'js/h5p-x-api-event.js', + 'js/h5p-x-api.js', + 'js/h5p-content-type.js', + 'js/h5p-confirmation-dialog.js', + 'js/h5p-action-bar.js', + 'js/request-queue.js', + ]; + static ADMIN_SCRIPTS = [ + 'js/jquery.js', + 'js/h5p-utils.js', + ]; + + // Disable flags + static DISABLE_NONE = 0; + static DISABLE_FRAME = 1; + static DISABLE_DOWNLOAD = 2; + static DISABLE_EMBED = 4; + static DISABLE_COPYRIGHT = 8; + static DISABLE_ABOUT = 16; + + static DISPLAY_OPTION_FRAME = 'frame'; + static DISPLAY_OPTION_DOWNLOAD = 'export'; + static DISPLAY_OPTION_EMBED = 'embed'; + static DISPLAY_OPTION_COPYRIGHT = 'copyright'; + static DISPLAY_OPTION_ABOUT = 'icon'; + static DISPLAY_OPTION_COPY = 'copy'; + protected CONTENT_TABLE = 'h5p_content'; // H5P content. protected LIBRARIES_TABLE = 'h5p_libraries'; // Installed libraries. protected LIBRARY_DEPENDENCIES_TABLE = 'h5p_library_dependencies'; // Library dependencies. + protected CONTENTS_LIBRARIES_TABLE = 'h5p_contents_libraries'; // Which library is used in which content. + protected LIBRARIES_CACHEDASSETS_TABLE = 'h5p_libraries_cachedassets'; // H5P cached library assets. + protected aggregateAssets = true; // Save all the assets from one package into a single file. protected siteSchema: CoreSiteSchema = { name: 'CoreH5PProvider', @@ -65,6 +107,11 @@ export class CoreH5PProvider { type: 'TEXT', notNull: true }, + { + name: 'fileurl', + type: 'TEXT', + notNull: true + }, { name: 'filtered', type: 'TEXT' @@ -177,6 +224,63 @@ export class CoreH5PProvider { notNull: true } ] + }, + { + name: this.CONTENTS_LIBRARIES_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true + }, + { + name: 'h5pid', + type: 'INTEGER', + notNull: true + }, + { + name: 'libraryid', + type: 'INTEGER', + notNull: true + }, + { + name: 'dependencytype', + type: 'TEXT', + notNull: true + }, + { + name: 'dropcss', + type: 'INTEGER', + notNull: true + }, + { + name: 'weight', + type: 'INTEGER', + notNull: true + } + ] + }, + { + name: this.LIBRARIES_CACHEDASSETS_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true + }, + { + name: 'libraryid', + type: 'INTEGER', + notNull: true + }, + { + name: 'hash', + type: 'TEXT', + notNull: true + } + ] } ] }; @@ -191,7 +295,10 @@ export class CoreH5PProvider { private textUtils: CoreTextUtilsProvider, private fileProvider: CoreFileProvider, private mimeUtils: CoreMimetypeUtilsProvider, - private h5pUtils: CoreH5PUtilsProvider) { + private h5pUtils: CoreH5PUtilsProvider, + private filepoolProvider: CoreFilepoolProvider, + private utils: CoreUtilsProvider, + private urlUtils: CoreUrlUtilsProvider) { this.logger = logger.getInstance('CoreH5PProvider'); @@ -204,6 +311,48 @@ export class CoreH5PProvider { }); } + /** + * Will concatenate all JavaScrips and Stylesheets into two files in order to improve page performance. + * + * @param files A set of all the assets required for content to display. + * @param key Hashed key for cached asset. + * @param folderName Name of the folder of the H5P package. + * @param siteId The site ID. + * @return Promise resolved when done. + */ + protected cacheAssets(files: {scripts: CoreH5PDependencyAsset[], styles: CoreH5PDependencyAsset[]}, key: string, + folderName: string, siteId: string): Promise { + + const promises = []; + + for (const type in files) { + const assets: CoreH5PDependencyAsset[] = files[type]; + + if (!assets || !assets.length) { + continue; + } + + // Create new file for cached assets. + const fileName = key + '.' + (type == 'scripts' ? 'js' : 'css'), + path = this.textUtils.concatenatePaths(this.getCachedAssetsFolderPath(folderName, siteId), fileName); + + // Store concatenated content. + promises.push(this.concatenateFiles(assets, type).then((content) => { + return this.fileProvider.writeFile(path, content); + }).then(() => { + // Now update the files data. + files[type] = [ + { + path: this.textUtils.concatenatePaths(this.getCachedAssetsFolderName(), fileName), + version: '' + } + ]; + })); + } + + return Promise.all(promises); + } + /** * Returns whether or not WS to get trusted H5P file is available. * @@ -252,6 +401,157 @@ export class CoreH5PProvider { }); } + /** + * Adds all files of a type into one file. + * + * @param assets A list of files. + * @param type The type of files in assets. Either 'scripts' or 'styles' + * @return Promise resolved with all of the files content in one string. + */ + protected concatenateFiles(assets: CoreH5PDependencyAsset[], type: string): Promise { + let content = '', + promise = Promise.resolve(); // Use a chain of promises so the order is kept. + + assets.forEach((asset) => { + + promise = promise.then(() => { + return this.fileProvider.readFile(asset.path); + }).then((fileContent: string) => { + if (type == 'scripts') { + content += fileContent + ';\n'; + } else { + // Rewrite relative URLs used inside stylesheets. + const matches = fileContent.match(/url\([\'"]?([^"\')]+)[\'"]?\)/ig), + assetPath = asset.path.replace(/(^\/|\/$)/g, ''), // Path without start/end slashes. + treated = {}; + + if (matches && matches.length) { + matches.forEach((match) => { + let url = match.replace(/(url\([\'"]?|[\'"]?\)$)/i, ''); + + if (treated[url] || url.match(/^(data:|([a-z0-9]+:)?\/)/i)) { + return; // Not relative or already treated, skip. + } + + treated[url] = url; + + /* Find "../" in the URL. If it exists, we have to remove "../" and switch the last folder in the + filepath for the first folder in the url. + For instance: + Path: /H5P.Question-1.4/styles/ + Url: ../images/plus-one.svg + We want this: H5P.Question-1.4/images/ITEMID/minus-one.svg. */ + if (url.match(/^\.\.\//)) { + const pathSplit = assetPath.split('/'), + urlSplit = url.split('/').filter((i) => { + return i; // Remove empty values. + }); + + // Remove the first element: ../. + urlSplit.unshift(); + + // Put the url's first folder into the asset path. + pathSplit[pathSplit.length - 1] = urlSplit[0]; + urlSplit.shift(); + + // Create the new URL and replace it in the file contents. + url = '/' + pathSplit.join('/') + '/' + urlSplit.join('/'); + + fileContent = fileContent.replace(new RegExp(this.textUtils.escapeForRegex(match), 'g'), + 'url("' + url + '")'); + } + }); + } + + content += fileContent + '\n'; + } + }); + }); + + return promise.then(() => { + return content; + }); + } + + /** + * Create the index.html to render an H5P package. + * + * @param id Content ID. + * @param h5pUrl The URL of the H5P file. + * @param content Content data. + * @param embedType Embed type. The app will always use 'iframe'. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the URL of the index file. + */ + createContentIndex(id: number, h5pUrl: string, content: CoreH5PContentData, embedType: string, siteId?: string) + : Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + + const disable = typeof content.disable != 'undefined' && content.disable != null ? + content.disable : CoreH5PProvider.DISABLE_NONE, + displayOptions = this.getDisplayOptionsForView(disable, id), + contentId = this.getContentId(id), + basePath = this.fileProvider.getBasePathInstant(), + contentUrl = this.textUtils.concatenatePaths(basePath, this.getContentFolderPath(content.folderName, site.getId())); + + // Create the settings needed for the content. + const contentSettings = { + library: this.libraryToString(content.library), + fullScreen: content.library.fullscreen, + exportUrl: '', // We'll never display the download button, so we don't need the exportUrl. + embedCode: this.getEmbedCode(site.getURL(), h5pUrl, displayOptions[CoreH5PProvider.DISPLAY_OPTION_EMBED]), + resizeCode: this.getResizeCode(), + title: content.slug, + displayOptions: displayOptions, + url: this.getEmbedUrl(site.getURL(), h5pUrl), + contentUrl: contentUrl, + metadata: content.metadata, + contentUserData: { + 0: { + state: '{}' + } + } + }; + + // Get the core H5P assets, needed by the H5P classes to render the H5P content. + return this.getAssets(id, content, embedType, site.getId()).then((result) => { + result.settings.contents[contentId] = Object.assign(result.settings.contents[contentId], contentSettings); + + const indexPath = this.getContentIndexPath(content.folderName, siteId); + let html = '' + content.title + '' + + ''; + + // @todo: Load the embed.js to allow communication with the parent window. + // $PAGE->requires->js(new moodle_url('/h5p/js/embed.js')); + + // Include the required CSS. + result.cssRequires.forEach((cssUrl) => { + html += ''; + }); + + // Add the settings. + html += ''; + + html += ''; + + // Include the required JS at the beginning of the body, like Moodle web does. + result.jsRequires.forEach((jsUrl) => { + html += ''; + }); + + html += '
' + + '' + + '
'; + + return this.fileProvider.writeFile(indexPath, html); + }).then((fileEntry) => { + return fileEntry.toURL(); + }); + }); + } + /** * Delete all the H5P data from the DB of a certain site. * @@ -263,11 +563,56 @@ export class CoreH5PProvider { return Promise.all([ db.deleteRecords(this.CONTENT_TABLE), db.deleteRecords(this.LIBRARIES_TABLE), - db.deleteRecords(this.LIBRARY_DEPENDENCIES_TABLE) + db.deleteRecords(this.LIBRARY_DEPENDENCIES_TABLE), + db.deleteRecords(this.CONTENTS_LIBRARIES_TABLE) ]); }); } + /** + * Delete cached assets from DB and filesystem. + * + * @param libraryId Library identifier. + * @param folderName Name of the folder of the H5P package. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected deleteCachedAssets(libraryId: number, folderName: string, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const db = site.getDb(), + cachedAssetsFolder = this.getCachedAssetsFolderPath(folderName, site.getId()); + + // Get all the hashes that use this library. + return db.getRecords(this.LIBRARIES_CACHEDASSETS_TABLE, {libraryid: libraryId}).then((entries) => { + // Delete the files with these hashes. + const promises = [], + hashes = []; + + entries.forEach((entry) => { + hashes.push(entry.hash); + + ['js', 'css'].forEach((type) => { + const path = this.textUtils.concatenatePaths(cachedAssetsFolder, entry.hash + '.' + type); + + promises.push(this.fileProvider.removeFile(path).catch(() => { + // Ignore errors, maybe there's no cached asset of this type. + })); + }); + }); + + // Also, delete the index.html file. + promises.push(this.fileProvider.removeFile(this.getContentIndexPath(folderName, site.getId())).catch(() => { + // Ignore errors. + })); + + return Promise.all(promises).then(() => { + return db.deleteRecordsList(this.LIBRARIES_CACHEDASSETS_TABLE, 'hash', hashes); + }); + }); + }); + } + /** * Delete content data from DB. * @@ -276,9 +621,17 @@ export class CoreH5PProvider { * @return Promise resolved when done. */ deleteContentData(id: number, siteId?: string): Promise { - return this.sitesProvider.getSiteDb(siteId).then((db) => { + const promises = []; + + // Delete the content data. + promises.push(this.sitesProvider.getSiteDb(siteId).then((db) => { return db.deleteRecords(this.CONTENT_TABLE, {id: id}); - }); + })); + + // Remove content library dependencies. + promises.push(this.deleteLibraryUsage(id, siteId)); + + return Promise.all(promises); } /** @@ -319,6 +672,19 @@ export class CoreH5PProvider { return this.fileProvider.removeDir(this.getLibraryFolderPath(libraryData, siteId, folderName)); } + /** + * Delete what libraries a content item is using. + * + * @param id Package ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + deleteLibraryUsage(id: number, siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.deleteRecords(this.CONTENTS_LIBRARIES_TABLE, {h5pid: id}); + }); + } + /** * 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. @@ -350,7 +716,7 @@ export class CoreH5PProvider { const content: any = {}; // Save the libraries that were processed. - return this.saveLibraries(data.librariesJsonData, siteId).then(() => { + return this.saveLibraries(data.librariesJsonData, folderName, siteId).then(() => { // Now treat contents. // Find main library version @@ -368,7 +734,7 @@ export class CoreH5PProvider { // Save the content data in DB. content.params = JSON.stringify(data.contentJsonData); - return this.saveContentData(content, folderName, siteId); + return this.saveContentData(content, folderName, fileUrl, siteId); }).then(() => { // Save the content files in their right place. const contentPath = this.textUtils.concatenatePaths(destFolder, 'content'); @@ -386,9 +752,374 @@ export class CoreH5PProvider { return this.fileProvider.removeDir(destFolder).catch(() => { // Ignore errors, it will be deleted eventually. }); + }).then(() => { + // Create the content player. + + return this.loadContentData(content.id, undefined, siteId).then((contentData) => { + const embedType = this.h5pUtils.determineEmbedType(contentData.embedType, contentData.library.embedTypes); + + return this.createContentIndex(content.id, fileUrl, contentData, embedType, siteId); + }); + }); + }); + }); + } + + /** + * Filter content run parameters and rebuild content dependency cache. + * + * @param content Content data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the filtered params, resolved with null if error. + */ + filterParameters(content: CoreH5PContentData, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (content.filtered) { + return Promise.resolve(content.filtered); + } + + if (typeof content.library == 'undefined' || typeof content.params == 'undefined') { + return Promise.resolve(null); + } + + const dependencies = {}, // In web, dependencies are built by the validator. + params = { + library: this.libraryToString(content.library), + params: this.textUtils.parseJSON(content.params, false) + }; + + if (!params.params) { + return null; + } + + // Get the main library data. + return this.loadLibrary(content.library.name, content.library.majorVersion, content.library.minorVersion, siteId) + .then((library) => { + + library.semantics = this.textUtils.parseJSON(library.semantics, ''); + + const depKey = 'preloaded-' + library.machineName; + let nextWeight; + + if (!dependencies[depKey]) { + dependencies[depKey] = { + library: library, + type: 'preloaded' + }; + } + + // Get the whole library dependency tree. + return this.findLibraryDependencies(dependencies, library, 1, false, siteId).then((weight) => { + nextWeight = weight; + dependencies[depKey].weight = nextWeight++; + + // Handle addons. + return this.loadAddons(siteId); + }).then((addons) => { + // Get the dependencies of all the addons. Use a chain of promises to calculate the weight properly. + let promise = Promise.resolve(); + + addons.forEach((addon) => { + const addTo = this.textUtils.parseJSON(addon.addTo, null); + + if (addTo && addTo.content && addTo.content.types && addTo.content.types.length) { + for (let i = 0; i < addTo.content.types.length; i++) { + const type = addTo.content.types[i]; + + if (type && type.text && type.text.regex && + this.h5pUtils.textAddonMatches(params.params, type.text.regex)) { + + const addonDepKey = 'preloaded-' + addon.machineName; + dependencies[addonDepKey] = { + library: addon, + type: 'preloaded' + }; + + promise = promise.then(() => { + return this.findLibraryDependencies(dependencies, addon, nextWeight).then((weight) => { + nextWeight = weight; + dependencies[addonDepKey].weight = nextWeight++; + }); + }); + + break; + } + } + } }); - // @todo: Load content? It's done in the player construct. + return promise; + }).then(() => { + // Update content dependencies. + content.dependencies = dependencies; + + const paramsStr = JSON.stringify(params.params); + + // Sometimes the parameters are filtered before content has been created + if (content.id) { + // Update library usage. + return this.deleteLibraryUsage(content.id, siteId).catch(() => { + // Ignore errors. + }).then(() => { + return this.saveLibraryUsage(content.id, content.dependencies, siteId); + }).then(() => { + if (!content.slug) { + content.slug = this.h5pUtils.slugify(content.title); + } + + // Cache. + return this.updateContentFields(content.id, {filtered: paramsStr}, siteId).then(() => { + return paramsStr; + }); + }); + } + + return paramsStr; + }); + }).catch(() => { + return null; + }); + } + + /** + * Recursive. Goes through the dependency tree for the given library and + * adds all the dependencies to the given array in a flat format. + * + * @param dependencies Object where to save the dependencies. + * @param library The library to find all dependencies for. + * @param nextWeight An integer determining the order of the libraries when they are loaded. + * @param editor Used internally to force all preloaded sub dependencies of an editor dependency to be editor dependencies. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the next weight. + */ + findLibraryDependencies(dependencies: {[key: string]: CoreH5PContentDepsTreeDependency}, + library: CoreH5PLibraryData | CoreH5PLibraryAddonData, nextWeight: number = 1, editor: boolean = false, + siteId?: string): Promise { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + let promise = Promise.resolve(); // We need to create a chain of promises to calculate the weight properly. + + ['dynamic', 'preloaded', 'editor'].forEach((type) => { + const property = type + 'Dependencies'; + + if (!library[property]) { + return; // Skip, no such dependencies. + } + + if (type === 'preloaded' && editor) { + // All preloaded dependencies of an editor library is set to editor. + type = 'editor'; + } + + library[property].forEach((dependency: CoreH5PLibraryBasicData) => { + const dependencyKey = type + '-' + dependency.machineName; + if (dependencies[dependencyKey]) { + return; // Skip, already have this. + } + + promise = promise.then(() => { + // Get the dependency library data and its subdependencies. + return this.loadLibrary(dependency.machineName, dependency.majorVersion, dependency.minorVersion, siteId) + .then((dependencyLibrary) => { + + dependencies[dependencyKey] = { + library: dependencyLibrary, + type: type + }; + + // Get all its subdependencies. + return this.findLibraryDependencies(dependencies, dependencyLibrary, nextWeight, type === 'editor', siteId); + }).then((weight) => { + nextWeight = weight; + dependencies[dependencyKey].weight = nextWeight++; + }); + }); + }); + }); + + return promise.then(() => { + return nextWeight; + }); + } + + /** + * Get the assets of a package. + * + * @param id Content id. + * @param content Content data. + * @param embedType Embed type. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the assets. + */ + protected getAssets(id: number, content: CoreH5PContentData, embedType: string, siteId?: string) + : Promise<{settings: any, cssRequires: string[], jsRequires: string[]}> { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const cssRequires = [], + jsRequires = [], + contentId = this.getContentId(id); + let settings; + + return this.getCoreSettings(id, siteId).then((coreSettings) => { + settings = coreSettings; + + settings.core = { + styles: [], + scripts: [] + }; + settings.loadedJs = []; + settings.loadedCss = []; + + const libUrl = this.getCoreH5PPath(), + relPath = this.urlUtils.removeProtocolAndWWW(libUrl); + + // Add core stylesheets. + CoreH5PProvider.STYLES.forEach((style) => { + settings.core.styles.push(relPath + style); + cssRequires.push(libUrl + style); + }); + + // Add core JavaScript. + this.getScripts().forEach((script) => { + settings.core.scripts.push(script); + jsRequires.push(script); + }); + + /* The filterParameters function should be called before getting the dependency files because it rebuilds content + dependency cache. */ + return this.filterParameters(content, siteId); + }).then((params) => { + settings.contents = settings.contents || {}; + settings.contents[contentId] = settings.contents[contentId] || {}; + settings.contents[contentId].jsonContent = params; + + return this.getContentDependencyFiles(id, content.folderName, siteId); + }).then((files) => { + + // H5P checks the embedType in here, but we'll always use iframe so there's no need to do it. + // JavaScripts and stylesheets will be loaded through h5p.js. + settings.contents[contentId].scripts = this.h5pUtils.getAssetsUrls(files.scripts); + settings.contents[contentId].styles = this.h5pUtils.getAssetsUrls(files.styles); + + return { + settings: settings, + cssRequires: cssRequires, + jsRequires: jsRequires + }; + }); + } + + /** + * Will check if there are cache assets available for content. + * + * @param key Hashed key for cached asset + * @param folderName Name of the folder of the H5P package. + * @param siteId The site ID. + * @return Promise resolved with the files. + */ + getCachedAssets(key: string, folderName: string, siteId: string) + : Promise<{scripts?: CoreH5PDependencyAsset[], styles?: CoreH5PDependencyAsset[]}> { + + const files: {scripts?: CoreH5PDependencyAsset[], styles?: CoreH5PDependencyAsset[]} = {}, + promises = [], + cachedAssetsName = this.getCachedAssetsFolderName(), + jsPath = this.textUtils.concatenatePaths(cachedAssetsName, key + '.js'), + cssPath = this.textUtils.concatenatePaths(cachedAssetsName, key + '.css'); + let found = false; + + promises.push(this.fileProvider.getFileSize(jsPath).then((size) => { + if (size > 0) { + found = true; + files.scripts = [ + { + path: jsPath, + version: '' + } + ]; + } + }).catch(() => { + // Not found. + })); + + promises.push(this.fileProvider.getFileSize(cssPath).then((size) => { + if (size > 0) { + found = true; + files.styles = [ + { + path: cssPath, + version: '' + } + ]; + } + }).catch(() => { + // Not found. + })); + + return Promise.all(promises).then(() => { + return found ? files : null; + }); + } + + /** + * Get folder name of the content cached assets. + * + * @return Name. + */ + getCachedAssetsFolderName(): string { + return 'cachedassets'; + } + + /** + * Get relative path to a content cached assets. + * + * @param folderName Name of the folder of the content the assets belong to. + * @param siteId Site ID. + * @return Path. + */ + getCachedAssetsFolderPath(folderName: string, siteId: string): string { + return this.textUtils.concatenatePaths(this.getContentFolderPath(folderName, siteId), this.getCachedAssetsFolderName()); + } + + /** + * Get the identifier for the H5P content. This identifier is different than the ID stored in the DB. + * + * @param id Package ID. + * @return Content identifier. + */ + protected getContentId(id: number): string { + return 'cid-' + id; + } + + /** + * Get conent data from DB. + * + * @param id Content ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the content data. + */ + protected getContentData(id: number, siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.getRecord(this.CONTENT_TABLE, {id: id}); + }); + } + + /** + * Get conent data from DB. + * + * @param fileUrl H5P file URL. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the content data. + */ + protected getContentDataByUrl(fileUrl: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const db = site.getDb(); + + return this.getContentFolderNameByUrl(fileUrl, site.getId()).then((folderName) => { + + return db.getRecord(this.CONTENT_TABLE, {foldername: folderName}); }); }); } @@ -401,7 +1132,339 @@ export class CoreH5PProvider { * @return Folder path. */ getContentFolderPath(folderName: string, siteId: string): string { - return this.textUtils.concatenatePaths(this.fileProvider.getSiteFolder(siteId), 'h5p/packages/' + folderName + '/content'); + return this.textUtils.concatenatePaths(this.getExternalH5PFolderPath(siteId), 'packages/' + folderName + '/content'); + } + + /** + * Get the content index file. + * + * @param fileUrl URL of the H5P package. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the file URL if exists, rejected otherwise. + */ + getContentIndexFileUrl(fileUrl: string, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.getContentFolderNameByUrl(fileUrl, siteId).then((folderName) => { + return this.fileProvider.getFile(this.getContentIndexPath(folderName, siteId)); + }).then((file) => { + return file.toURL(); + }); + } + + /** + * Get the path to a content index. + * + * @param folderName Name of the folder of the H5P package. + * @param siteId The site ID. + * @return Folder path. + */ + getContentIndexPath(folderName: string, siteId: string): string { + return this.textUtils.concatenatePaths(this.getContentFolderPath(folderName, siteId), 'index.html'); + } + + /** + * Get a content folder name given the package URL. + * + * @param fileUrl Package URL. + * @param siteId Site ID. + * @return Promise resolved with the folder name. + */ + getContentFolderNameByUrl(fileUrl: string, siteId: string): Promise { + return this.filepoolProvider.getFilePathByUrl(siteId, fileUrl).then((path) => { + + const fileAndDir = this.fileProvider.getFileAndDirectoryFromPath(path); + + return this.mimeUtils.removeExtension(fileAndDir.name); + }); + } + + /** + * Get the path to the folder that contains the H5P core libraries. + * + * @return Folder path. + */ + getCoreH5PPath(): string { + return this.textUtils.concatenatePaths(this.fileProvider.getWWWPath(), '/h5p/'); + } + + /** + * Get the settings needed by the H5P library. + * + * @param id The H5P content ID. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the settings. + */ + getCoreSettings(id: number, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + + const basePath = this.fileProvider.getBasePathInstant(), + ajaxPaths: any = {}; + ajaxPaths.xAPIResult = ''; + ajaxPaths.contentUserData = ''; + + return { + baseUrl: this.fileProvider.getWWWPath(), + url: this.textUtils.concatenatePaths(basePath, this.getExternalH5PFolderPath(site.getId())), + urlLibraries: this.textUtils.concatenatePaths(basePath, this.getLibrariesFolderPath(site.getId())), + postUserStatistics: false, + ajax: ajaxPaths, + saveFreq: false, + siteUrl: site.getURL(), + l10n: { + H5P: this.h5pUtils.getLocalization() + }, + user: [], + hubIsEnabled: false, + reportingIsEnabled: false, + crossorigin: null, + libraryConfig: null, + pluginCacheBuster: '', + libraryUrl: this.textUtils.concatenatePaths(this.getCoreH5PPath(), 'js') + }; + }); + } + + /** + * Finds library dependencies files of a certain package. + * + * @param id Content id. + * @param folderName Name of the folder of the content. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + protected getContentDependencyFiles(id: number, folderName: string, siteId?: string) + : Promise<{scripts: CoreH5PDependencyAsset[], styles: CoreH5PDependencyAsset[]}> { + + return this.loadContentDependencies(id, 'preloaded', siteId).then((dependencies) => { + return this.getDependenciesFiles(dependencies, folderName, this.getExternalH5PFolderPath(siteId), siteId); + }); + } + + /** + * Get all dependency assets of the given type. + * + * @param dependency The dependency. + * @param type Type of assets to get. + * @param assets Array where to store the assets. + * @param prefix Make paths relative to another dir. + */ + protected getDependencyAssets(dependency: CoreH5PContentDependencyData, type: string, assets: CoreH5PDependencyAsset[], + prefix: string = ''): void { + + // Check if dependency has any files of this type + if (!dependency[type] || dependency[type][0] === '') { + return; + } + + // Check if we should skip CSS. + if (type === 'preloadedCss' && this.utils.isTrueOrOne(dependency.dropCss)) { + return; + } + + for (const key in dependency[type]) { + const file = dependency[type][key]; + + assets.push({ + path: prefix + '/' + dependency.path + '/' + (typeof file != 'string' ? file.path : file).trim(), + version: dependency.version + }); + } + } + + /** + * Return file paths for all dependencies files. + * + * @param dependencies The dependencies to get the files. + * @param folderName Name of the folder of the content. + * @param prefix Make paths relative to another dir. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + protected getDependenciesFiles(dependencies: {[machineName: string]: CoreH5PContentDependencyData}, folderName: string, + prefix: string = '', siteId?: string): Promise<{scripts: CoreH5PDependencyAsset[], styles: CoreH5PDependencyAsset[]}> { + + // Build files list for assets. + const files = { + scripts: [], + styles: [] + }; + + // Avoid caching empty files. + if (!Object.keys(dependencies).length) { + return Promise.resolve(files); + } + + let promise, + cachedAssetsHash; + + if (this.aggregateAssets) { + // Get aggregated files for assets. + cachedAssetsHash = this.h5pUtils.getDependenciesHash(dependencies); + + promise = this.getCachedAssets(cachedAssetsHash, folderName, siteId); + } else { + promise = Promise.resolve(null); + } + + return promise.then((cachedAssets) => { + if (cachedAssets) { + // Cached assets found, return them. + return Object.assign(files, cachedAssets); + } + + // No cached assets, use content dependencies. + for (const key in dependencies) { + const dependency = dependencies[key]; + + if (!dependency.path) { + dependency.path = this.getDependencyPath(dependency); + dependency.preloadedJs = ( dependency.preloadedJs).split(','); + dependency.preloadedCss = ( dependency.preloadedCss).split(','); + } + + dependency.version = '?ver=' + dependency.majorVersion + '.' + dependency.minorVersion + '.' + + dependency.patchVersion; + + this.getDependencyAssets(dependency, 'preloadedJs', files.scripts, prefix); + this.getDependencyAssets(dependency, 'preloadedCss', files.styles, prefix); + } + + if (this.aggregateAssets) { + // Aggregate and store assets. + return this.cacheAssets(files, cachedAssetsHash, folderName, siteId).then(() => { + // Keep track of which libraries have been cached in case they are updated. + return this.saveCachedAssets(cachedAssetsHash, dependencies, siteId); + }).then(() => { + return files; + }); + } + + return files; + }); + } + + /** + * Get the path to the dependency. + * + * @param dependency Dependency library. + * @return The path to the dependency library + */ + protected getDependencyPath(dependency: CoreH5PContentDependencyData): string { + return 'libraries/' + dependency.machineName + '-' + dependency.majorVersion + '.' + dependency.minorVersion; + } + + /** + * Get the paths to the content dependencies. + * + * @param id The H5P content ID. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with an object containing the path of each content dependency. + */ + getDependencyRoots(id: number, siteId?: string): Promise<{[libString: string]: string}> { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const roots = {}; + + return this.loadContentDependencies(id, undefined, siteId).then((dependencies) => { + + for (const machineName in dependencies) { + const dependency = dependencies[machineName], + folderName = this.libraryToString(dependency, true); + + roots[folderName] = this.getLibraryFolderPath(dependency, siteId, folderName); + } + + return roots; + }); + } + + /** + * Convert display options to an object. + * + * @param disable Display options as a number. + * @return Display options as object. + */ + getDisplayOptionsAsObject(disable: number): CoreH5PDisplayOptions { + const displayOptions: CoreH5PDisplayOptions = {}; + + // tslint:disable: no-bitwise + displayOptions[CoreH5PProvider.DISPLAY_OPTION_FRAME] = !(disable & CoreH5PProvider.DISABLE_FRAME); + displayOptions[CoreH5PProvider.DISPLAY_OPTION_DOWNLOAD] = !(disable & CoreH5PProvider.DISABLE_DOWNLOAD); + displayOptions[CoreH5PProvider.DISPLAY_OPTION_EMBED] = !(disable & CoreH5PProvider.DISABLE_EMBED); + displayOptions[CoreH5PProvider.DISPLAY_OPTION_COPYRIGHT] = !(disable & CoreH5PProvider.DISABLE_COPYRIGHT); + displayOptions[CoreH5PProvider.DISPLAY_OPTION_ABOUT] = !!this.getOption(CoreH5PProvider.DISPLAY_OPTION_ABOUT, true); + + return displayOptions; + } + + /** + * Determine display option visibility when viewing H5P + * + * @param disable The display options as a number. + * @param id Package ID. + * @return Display options as object. + */ + getDisplayOptionsForView(disable: number, id: number): CoreH5PDisplayOptions { + const displayOptions = this.getDisplayOptionsAsObject(disable); + + if (this.getOption(CoreH5PProvider.DISPLAY_OPTION_FRAME, true) == false) { + displayOptions[CoreH5PProvider.DISPLAY_OPTION_FRAME] = false; + } else { + displayOptions[CoreH5PProvider.DISPLAY_OPTION_DOWNLOAD] = this.setDisplayOptionOverrides( + CoreH5PProvider.DISPLAY_OPTION_DOWNLOAD, CoreH5PPermission.DOWNLOAD_H5P, id, + displayOptions[CoreH5PProvider.DISPLAY_OPTION_DOWNLOAD]); + + displayOptions[CoreH5PProvider.DISPLAY_OPTION_EMBED] = this.setDisplayOptionOverrides( + CoreH5PProvider.DISPLAY_OPTION_EMBED, CoreH5PPermission.EMBED_H5P, id, + displayOptions[CoreH5PProvider.DISPLAY_OPTION_EMBED]); + + if (this.getOption(CoreH5PProvider.DISPLAY_OPTION_COPYRIGHT, true) == false) { + displayOptions[CoreH5PProvider.DISPLAY_OPTION_COPYRIGHT] = false; + } + } + + displayOptions[CoreH5PProvider.DISPLAY_OPTION_COPY] = this.hasPermission(CoreH5PPermission.COPY_H5P, id); + + return displayOptions; + } + + /** + * Embed code for settings. + * + * @param siteUrl The site URL. + * @param h5pUrl The URL of the .h5p file. + * @param embedEnabled Whether the option to embed the H5P content is enabled. + * @return The HTML code to reuse this H5P content in a different place. + */ + protected getEmbedCode(siteUrl: string, h5pUrl: string, embedEnabled?: boolean): string { + if (!embedEnabled) { + return ''; + } + + return ''; + } + + /** + * Get the encoded URL for embeding an H5P content. + * + * @param siteUrl The site URL. + * @param h5pUrl The URL of the .h5p file. + * @return The embed URL. + */ + protected getEmbedUrl(siteUrl: string, h5pUrl: string): string { + return this.textUtils.concatenatePaths(siteUrl, '/h5p/embed.php') + '?url=' + h5pUrl; + } + + /** + * Get path to the folder containing H5P files extracted from packages. + * + * @param siteId The site ID. + * @return Folder path. + */ + getExternalH5PFolderPath(siteId: string): string { + return this.textUtils.concatenatePaths(this.fileProvider.getSiteFolder(siteId), 'h5p'); } /** @@ -514,6 +1577,19 @@ export class CoreH5PProvider { return this.getLibrary(libraryData.machineName, libraryData.majorVersion, libraryData.minorVersion, siteId); } + /** + * Get a library data stored in DB by ID. + * + * @param id Library ID. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library data, rejected if not found. + */ + protected getLibraryById(id: number, siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.getRecord(this.LIBRARIES_TABLE, {id: id}); + }); + } + /** * Get a library ID. If not found, return null. * @@ -551,7 +1627,7 @@ export class CoreH5PProvider { * @return Folder path. */ getLibrariesFolderPath(siteId: string): string { - return this.textUtils.concatenatePaths(this.fileProvider.getSiteFolder(siteId), 'h5p/lib'); + return this.textUtils.concatenatePaths(this.getExternalH5PFolderPath(siteId), 'libraries'); } /** @@ -570,6 +1646,44 @@ export class CoreH5PProvider { return this.textUtils.concatenatePaths(this.getLibrariesFolderPath(siteId), folderName); } + /** + * Get the default behaviour for the display option defined. + * + * @param name Identifier for the setting. + * @param defaultValue Optional default value if settings is not set. + * @return Return the value for this display option. + */ + getOption(name: string, defaultValue: any = false): any { + // For now, all them are disabled by default, so only will be rendered when defined in the displayoptions DB field. + return 2; // CONTROLLED_BY_AUTHOR_DEFAULT_OFF. + } + + /** + * Resizing script for settings. + * + * @return The HTML code with the resize script. + */ + protected getResizeCode(): string { + // @todo return ''; + return ''; + } + + /** + * Get core JavaScript files. + * + * @return array The array containg urls of the core JavaScript files: + */ + getScripts(): string[] { + const libUrl = this.getCoreH5PPath(), + urls = []; + + CoreH5PProvider.SCRIPTS.forEach((script) => { + urls.push(libUrl + script); + }); + + return urls; + } + /** * Get a trusted H5P file. * @@ -636,6 +1750,17 @@ export class CoreH5PProvider { return this.ROOT_CACHE_KEY + 'trustedH5PFile:'; } + /** + * Check whether the user has permission to execute an action. + * + * @param permission Permission to check. + * @param id H5P package id. + * @return Whether the user has permission to execute an action. + */ + hasPermission(permission: number, id: number): boolean { + return true; + } + /** * Invalidates all trusted H5P file WS calls. * @@ -673,6 +1798,196 @@ export class CoreH5PProvider { libraryData.majorVersion + '.' + libraryData.minorVersion; } + /** + * Load addon libraries. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the addon libraries. + */ + loadAddons(siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + + const query = 'SELECT l1.id AS libraryId, l1.machinename AS machineName, ' + + 'l1.majorversion AS majorVersion, l1.minorversion AS minorVersion, ' + + 'l1.patchversion AS patchVersion, l1.addto AS addTo, ' + + 'l1.preloadedjs AS preloadedJs, l1.preloadedcss AS preloadedCss ' + + 'FROM ' + this.LIBRARIES_TABLE + ' l1 ' + + 'JOIN ' + this.LIBRARIES_TABLE + ' l2 ON l1.machinename = l2.machinename AND (' + + 'l1.majorversion < l2.majorversion OR (l1.majorversion = l2.majorversion AND ' + + 'l1.minorversion < l2.minorversion)) ' + + 'WHERE l1.addto IS NOT NULL AND l2.machinename IS NULL'; + + return db.execute(query).then((result) => { + const addons = []; + + for (let i = 0; i < result.rows.length; i++) { + addons.push(result.rows.item(i)); + } + + return addons; + }); + }); + } + + /** + * Load content data from DB. + * + * @param id Content ID. + * @param fileUrl H5P file URL. Required if id is not provided. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the content data. + */ + protected loadContentData(id?: number, fileUrl?: string, siteId?: string): Promise { + let promise: Promise; + + if (id) { + promise = this.getContentData(id, siteId); + } else if (fileUrl) { + promise = this.getContentDataByUrl(fileUrl, siteId); + } else { + promise = Promise.reject(null); + } + + return promise.then((contentData) => { + + // Load the main library data. + return this.getLibraryById(contentData.mainlibraryid, siteId).then((libData) => { + + // Map the values to the names used by the H5P core (it's the same Moodle web does). + return { + id: contentData.id, + params: contentData.jsoncontent, + // The embedtype will be always set to 'iframe' to prevent conflicts with JS and CSS. + embedType: 'iframe', + disable: contentData.displayoptions, + folderName: contentData.foldername, + title: libData.title, + slug: this.h5pUtils.slugify(libData.title) + '-' + contentData.id, + filtered: contentData.filtered, + libraryMajorVersion: libData.majorversion, + libraryMinorVersion: libData.minorversion, + metadata: { + license: 'U' // Stop "invalid selected option in select" for old content without license chosen. + }, + library: { + id: libData.id, + name: libData.machinename, + majorVersion: libData.majorversion, + minorVersion: libData.minorversion, + embedTypes: libData.embedtypes, + fullscreen: libData.fullscreen + } + }; + }); + }); + } + + /** + * Load dependencies for the given content of the given type. + * + * @param id Content ID. + * @param type The dependency type. + * @return Content dependencies, indexed by machine name. + */ + loadContentDependencies(id: number, type?: string, siteId?: string) + : Promise<{[machineName: string]: CoreH5PContentDependencyData}> { + + return this.sitesProvider.getSiteDb(siteId).then((db) => { + let query = 'SELECT hl.id AS libraryId, hl.machinename AS machineName, ' + + 'hl.majorversion AS majorVersion, hl.minorversion AS minorVersion, ' + + 'hl.patchversion AS patchVersion, hl.preloadedcss AS preloadedCss, ' + + 'hl.preloadedjs AS preloadedJs, hcl.dropcss AS dropCss, ' + + 'hcl.dependencytype as dependencyType ' + + 'FROM ' + this.CONTENTS_LIBRARIES_TABLE + ' hcl ' + + 'JOIN ' + this.LIBRARIES_TABLE + ' hl ON hcl.libraryid = hl.id ' + + 'WHERE hcl.h5pid = ?'; + const queryArgs = []; + queryArgs.push(id); + + if (type) { + query += ' AND hcl.dependencytype = ?'; + queryArgs.push(type); + } + + query += ' ORDER BY hcl.weight'; + + return db.execute(query, queryArgs).then((result) => { + const dependencies = {}; + + for (let i = 0; i < result.rows.length; i++) { + const dependency = result.rows.item(i); + + dependencies[dependency.machineName] = dependency; + } + + return dependencies; + }); + }); + } + + /** + * Loads a library and its dependencies. + * + * @param machineName The library's machine name. + * @param majorVersion The library's major version. + * @param minorVersion The library's minor version. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library data. + */ + loadLibrary(machineName: string, majorVersion: number, minorVersion: number, siteId?: string): Promise { + + // First get the library data from DB. + return this.getLibrary(machineName, majorVersion, minorVersion, siteId).then((library) => { + const libraryData: CoreH5PLibraryData = { + libraryId: library.id, + title: library.title, + machineName: library.machinename, + majorVersion: library.majorversion, + minorVersion: library.minorversion, + patchVersion: library.patchversion, + runnable: library.runnable, + fullscreen: library.fullscreen, + embedTypes: library.embedtypes, + preloadedJs: library.preloadedjs, + preloadedCss: library.preloadedcss, + dropLibraryCss: library.droplibrarycss, + semantics: library.semantics, + preloadedDependencies: [], + dynamicDependencies: [], + editorDependencies: [] + }; + + // Now get the dependencies. + const sql = 'SELECT hl.id, hl.machinename, hl.majorversion, hl.minorversion, hll.dependencytype ' + + 'FROM ' + this.LIBRARY_DEPENDENCIES_TABLE + ' hll ' + + 'JOIN ' + this.LIBRARIES_TABLE + ' hl ON hll.requiredlibraryid = hl.id ' + + 'WHERE hll.libraryid = ? ' + + 'ORDER BY hl.id ASC'; + + const sqlParams = [ + library.id + ]; + + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.execute(sql, sqlParams).then((result) => { + + for (let i = 0; i < result.rows.length; i++) { + const dependency = result.rows.item(i), + key = dependency.dependencytype + 'Dependencies'; + + libraryData[key].push({ + machineName: dependency.machinename, + majorVersion: dependency.majorversion, + minorVersion: dependency.minorversion + }); + } + + return libraryData; + }); + }); + }); + } + /** * 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. @@ -727,14 +2042,44 @@ export class CoreH5PProvider { }); } + /** + * Stores hash keys for cached assets, aggregated JavaScripts and stylesheets, and connects it to libraries so that we + * know which cache file to delete when a library is updated. + * + * @param key Hash key for the given libraries. + * @param libraries List of dependencies used to create the key + * @param siteId The site ID. + * @return Promise resolved when done. + */ + protected saveCachedAssets(hash: string, dependencies: {[machineName: string]: CoreH5PContentDependencyData}, + siteId?: string): Promise { + + return this.sitesProvider.getSiteDb(siteId).then((db) => { + const promises = []; + + for (const key in dependencies) { + const data = { + hash: key, + libraryid: dependencies[key].libraryId + }; + + promises.push(db.insertRecord(this.LIBRARIES_CACHEDASSETS_TABLE, data)); + } + + return Promise.all(promises); + }); + } + /** * Save content data in DB and clear cache. * * @param content Content to save. * @param folderName The name of the folder that contains the H5P. + * @param fileUrl The online URL of the package. + * @param siteId Site ID. If not defined, current site. * @return Promise resolved with content ID. */ - protected saveContentData(content: any, folderName: string, siteId?: string): Promise { + protected saveContentData(content: any, folderName: string, fileUrl: string, siteId?: string): Promise { // Save in DB. return this.sitesProvider.getSiteDb(siteId).then((db) => { @@ -744,7 +2089,8 @@ export class CoreH5PProvider { mainlibraryid: content.library.libraryId, timemodified: Date.now(), filtered: null, - foldername: folderName + foldername: folderName, + fileurl: fileUrl }; if (typeof content.id != 'undefined') { @@ -791,10 +2137,13 @@ export class CoreH5PProvider { * Save libraries. This code is based on the saveLibraries function from Moodle's H5PStorage. * * @param librariesJsonData Data about libraries. + * @param folderName Name of the folder of the H5P package. * @param siteId Site ID. If not defined, current site. * @return Promise resolved when done. */ - protected saveLibraries(librariesJsonData: any, siteId?: string): Promise { + protected saveLibraries(librariesJsonData: any, folderName: string, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + 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. @@ -839,7 +2188,10 @@ export class CoreH5PProvider { }); }); }).then(() => { - // @todo: Remove cached asses that use this library. + // Remove cached assets that use this library. + if (this.aggregateAssets && typeof libraryData.libraryId != 'undefined') { + return this.deleteCachedAssets(libraryData.libraryId, folderName, siteId); + } }); })); } @@ -1002,6 +2354,77 @@ export class CoreH5PProvider { }); } + /** + * Saves what libraries the content uses. + * + * @param id Id identifying the package. + * @param librariesInUse List of libraries the content uses. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + saveLibraryUsage(id: number, librariesInUse: {[key: string]: CoreH5PContentDepsTreeDependency}, siteId?: string) + : Promise { + + return this.sitesProvider.getSiteDb(siteId).then((db) => { + // Calculate the CSS to drop. + const dropLibraryCssList = {}, + promises = []; + + for (const key in librariesInUse) { + const dependency = librariesInUse[key]; + + if (dependency.library.dropLibraryCss) { + const split = dependency.library.dropLibraryCss.split(', '); + + split.forEach((css) => { + dropLibraryCssList[css] = css; + }); + } + } + + for (const key in librariesInUse) { + const dependency = librariesInUse[key], + data = { + h5pid: id, + libraryId: dependency.library.libraryId, + dependencytype: dependency.type, + dropcss: dropLibraryCssList[dependency.library.machineName] ? 1 : 0, + weight: dependency.weight + }; + + promises.push(db.insertRecord(this.CONTENTS_LIBRARIES_TABLE, data)); + } + + return Promise.all(promises); + }); + + } + + /** + * Helper function used to figure out embed and download behaviour. + * + * @param optionName The option name. + * @param permission The permission. + * @param id The package ID. + * @param value Default value. + * @return The value to use. + */ + setDisplayOptionOverrides(optionName: string, permission: number, id: number, value: boolean): boolean { + const behaviour = this.getOption(optionName, CoreH5PDisplayOptionBehaviour.ALWAYS_SHOW); + + // If never show globally, force hide + if (behaviour == CoreH5PDisplayOptionBehaviour.NEVER_SHOW) { + value = false; + } else if (behaviour == CoreH5PDisplayOptionBehaviour.ALWAYS_SHOW) { + // If always show or permissions say so, force show + value = true; + } else if (behaviour == CoreH5PDisplayOptionBehaviour.CONTROLLED_BY_PERMISSIONS) { + value = this.hasPermission(permission, id); + } + + return value; + } + /** * Treat an H5P url before sending it to WS. * @@ -1016,8 +2439,60 @@ export class CoreH5PProvider { return url; } + + /** + * This will update selected fields on the given content. + * + * @param id Content identifier. + * @param fields Object with the fields to update. + * @param siteId Site ID. If not defined, current site. + */ + protected updateContentFields(id: number, fields: any, siteId?: string): Promise { + + return this.sitesProvider.getSiteDb(siteId).then((db) => { + const data = Object.assign(fields); + delete data.slug; // Slug isn't stored in DB. + + return db.updateRecords(this.CONTENT_TABLE, data, {id: id}); + }); + } } +/** + * Display options behaviour constants. + */ +export class CoreH5PDisplayOptionBehaviour { + static NEVER_SHOW = 0; + static CONTROLLED_BY_AUTHOR_DEFAULT_ON = 1; + static CONTROLLED_BY_AUTHOR_DEFAULT_OFF = 2; + static ALWAYS_SHOW = 3; + static CONTROLLED_BY_PERMISSIONS = 4; +} + +/** + * Permission constants. + */ +export class CoreH5PPermission { + static DOWNLOAD_H5P = 0; + static EMBED_H5P = 1; + static CREATE_RESTRICTED = 2; + static UPDATE_LIBRARIES = 3; + static INSTALL_RECOMMENDED = 4; + static COPY_H5P = 4; +} + +/** + * Display options as object. + */ +export type CoreH5PDisplayOptions = { + frame?: boolean; + export?: boolean; + embed?: boolean; + copyright?: boolean; + icon?: boolean; + copy?: boolean; +}; + /** * Options for core_h5p_get_trusted_h5p_file. */ @@ -1036,6 +2511,14 @@ export type CoreH5PGetTrustedH5PFileResult = { warnings: CoreWSExternalWarning[]; // List of warnings. }; +/** + * Dependency asset. + */ +export type CoreH5PDependencyAsset = { + path: string; // Path to the asset. + version: string; // Dependency version. +}; + /** * Content data stored in DB. */ @@ -1045,11 +2528,109 @@ export type CoreH5PContentDBData = { 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. + fileurl: string; // The online URL of the H5P package. filtered: string; // Filtered version of json_content. timecreated: number; // Time created. timemodified: number; // Time modified. }; +/** + * Content data, including main library data. + */ +export type CoreH5PContentData = { + id: number; // The id of the content. + params: string; // The content in json format. + embedType: string; // Embed type to use. + disable: number; // H5P Button display options. + folderName: string; // Name of the folder that contains the contents. + title: string; // Main library's title. + slug: string; // Lib title and ID slugified. + filtered: string; // Filtered version of json_content. + libraryMajorVersion: number; // Main library's major version. + libraryMinorVersion: number; // Main library's minor version. + metadata: any; // Content metadata. + library: { // Main library data. + id: number; // The id of the library. + name: string; // The library machine name. + majorVersion: number; // Major version. + minorVersion: number; // Minor version. + embedTypes: string; // List of supported embed types. + fullscreen: number; // Display fullscreen button. + }; + dependencies?: {[key: string]: CoreH5PContentDepsTreeDependency}; // Dependencies. Calculated in filterParameters. +}; + +/** + * Content dependency data. + */ +export type CoreH5PContentDependencyData = { + libraryId: number; // The id of the library if it is an existing library. + machineName: string; // The library machineName. + majorVersion: number; // The The library's majorVersion. + minorVersion: number; // The The library's minorVersion. + patchVersion: number; // The The library's patchVersion. + preloadedJs?: string | string[]; // Comma separated string with js file paths. If already parsed, list of paths. + preloadedCss?: string | string[]; // Comma separated string with css file paths. If already parsed, list of paths. + dropCss?: string; // CSV of machine names. + dependencyType: string; // The dependency type. + path?: string; // Path to the dependency. Calculated in getDependenciesFiles. + version?: string; // Version of the dependency. Calculated in getDependenciesFiles. +}; + +/** + * Data for each content dependency in the dependency tree. + */ +export type CoreH5PContentDepsTreeDependency = { + library: CoreH5PLibraryData; // Library data. + type: string; // Dependency type. + weight?: number; // An integer determining the order of the libraries when they are loaded. +}; + +/** + * Library data. + */ +export type CoreH5PLibraryData = { + libraryId: number; // The id of the library. + title: string; // The human readable name of this library. + machineName: string; // The library machine name. + 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?: any; // The semantics definition. If it's a string, it's in json format. + preloadedDependencies: CoreH5PLibraryBasicData[]; // Dependencies. + dynamicDependencies: CoreH5PLibraryBasicData[]; // Dependencies. + editorDependencies: CoreH5PLibraryBasicData[]; // Dependencies. +}; + +/** + * Library basic data. + */ +export type CoreH5PLibraryBasicData = { + machineName: string; // The library machine name. + majorVersion: number; // Major version. + minorVersion: number; // Minor version. +}; + +/** + * "Addon" data (library). + */ +export type CoreH5PLibraryAddonData = { + libraryId: number; // The id of the library. + machineName: string; // The library machine name. + majorVersion: number; // Major version. + minorVersion: number; // Minor version. + patchVersion: number; // Patch version. + preloadedJs?: string; // Comma separated list of scripts to load. + preloadedCss?: string; // Comma separated list of stylesheets to load. + addTo?: string; // Plugin configuration data. +}; + /** * Library data stored in DB. */ diff --git a/src/core/h5p/providers/utils.ts b/src/core/h5p/providers/utils.ts index 3a36071cb..9c568acac 100644 --- a/src/core/h5p/providers/utils.ts +++ b/src/core/h5p/providers/utils.ts @@ -13,6 +13,10 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreH5PContentDependencyData, CoreH5PDependencyAsset } from './h5p'; +import { Md5 } from 'ts-md5/dist/md5'; /** * Utils service with helper functions for H5P. @@ -20,9 +24,120 @@ import { Injectable } from '@angular/core'; @Injectable() export class CoreH5PUtilsProvider { - constructor() { - // Nothing to do. - } + // Map to slugify characters. + protected SLUGIFY_MAP = { + æ: 'ae', + ø: 'oe', + ö: 'o', + ó: 'o', + ô: 'o', + Ò: 'oe', + Õ: 'o', + Ý: 'o', + ý: 'y', + ÿ: 'y', + ā: 'y', + ă: 'a', + ą: 'a', + œ: 'a', + å: 'a', + ä: 'a', + á: 'a', + à: 'a', + â: 'a', + ã: 'a', + ç: 'c', + ć: 'c', + ĉ: 'c', + ċ: 'c', + č: 'c', + é: 'e', + è: 'e', + ê: 'e', + ë: 'e', + í: 'i', + ì: 'i', + î: 'i', + ï: 'i', + ú: 'u', + ñ: 'n', + ü: 'u', + ù: 'u', + û: 'u', + ß: 'es', + ď: 'd', + đ: 'd', + ē: 'e', + ĕ: 'e', + ė: 'e', + ę: 'e', + ě: 'e', + ĝ: 'g', + ğ: 'g', + ġ: 'g', + ģ: 'g', + ĥ: 'h', + ħ: 'h', + ĩ: 'i', + ī: 'i', + ĭ: 'i', + į: 'i', + ı: 'i', + ij: 'ij', + ĵ: 'j', + ķ: 'k', + ĺ: 'l', + ļ: 'l', + ľ: 'l', + ŀ: 'l', + ł: 'l', + ń: 'n', + ņ: 'n', + ň: 'n', + ʼn: 'n', + ō: 'o', + ŏ: 'o', + ő: 'o', + ŕ: 'r', + ŗ: 'r', + ř: 'r', + ś: 's', + ŝ: 's', + ş: 's', + š: 's', + ţ: 't', + ť: 't', + ŧ: 't', + ũ: 'u', + ū: 'u', + ŭ: 'u', + ů: 'u', + ű: 'u', + ų: 'u', + ŵ: 'w', + ŷ: 'y', + ź: 'z', + ż: 'z', + ž: 'z', + ſ: 's', + ƒ: 'f', + ơ: 'o', + ư: 'u', + ǎ: 'a', + ǐ: 'i', + ǒ: 'o', + ǔ: 'u', + ǖ: 'u', + ǘ: 'u', + ǚ: 'u', + ǜ: 'u', + ǻ: 'a', + ǽ: 'ae', + ǿ: 'oe' + }; + + constructor(private translate: TranslateService, + private textUtils: CoreTextUtilsProvider) { } /** * The metadataSettings field in libraryJson uses 1 for true and 0 for false. @@ -43,6 +158,161 @@ export class CoreH5PUtilsProvider { return JSON.stringify(metadataSettings); } + /** + * Determine the correct embed type to use. + * + * @param Embed type of the content. + * @param Embed type of the main library. + * @return Either 'div' or 'iframe'. + */ + determineEmbedType(contentEmbedType: string, libraryEmbedTypes: string): string { + // Detect content embed type. + let embedType = contentEmbedType.toLowerCase().indexOf('div') != -1 ? 'div' : 'iframe'; + + if (libraryEmbedTypes) { + // Check that embed type is available for library + const embedTypes = libraryEmbedTypes.toLowerCase(); + + if (embedTypes.indexOf(embedType) == -1) { + // Not available, pick default. + embedType = embedTypes.indexOf('div') != -1 ? 'div' : 'iframe'; + } + } + + return embedType; + } + + /** + * Combines path with version. + * + * @param assets List of assets to get their URLs. + * @param assetsFolderPath The path of the folder where the assets are. + * @return List of urls. + */ + getAssetsUrls(assets: CoreH5PDependencyAsset[], assetsFolderPath: string = ''): string[] { + const urls = []; + + assets.forEach((asset) => { + let url = asset.path; + + // Add URL prefix if not external. + if (asset.path.indexOf('://') == -1 && assetsFolderPath) { + url = this.textUtils.concatenatePaths(assetsFolderPath, url); + } + + // Add version if set. + if (asset.version) { + url += asset.version; + } + + urls.push(url); + }); + + return urls; + } + + /** + * Get the hash of a list of dependencies. + * + * @param dependencies Dependencies. + * @return Hash. + */ + getDependenciesHash(dependencies: {[machineName: string]: CoreH5PContentDependencyData}): string { + // Build hash of dependencies. + const toHash = []; + + // Use unique identifier for each library version. + for (const name in dependencies) { + const dep = dependencies[name]; + toHash.push(dep.machineName + '-' + dep.majorVersion + '.' + dep.minorVersion + '.' + dep.patchVersion); + } + + // Sort in case the same dependencies comes in a different order. + toHash.sort((a, b) => { + return a.localeCompare(b); + }); + + // Calculate hash. + return Md5.hashAsciiStr(toHash.join('')); + } + + /** + * Provide localization for the Core JS. + * + * @return Object with the translations. + */ + getLocalization(): any { + return { + fullscreen: this.translate.instant('core.h5p.fullscreen'), + disableFullscreen: this.translate.instant('core.h5p.disablefullscreen'), + download: this.translate.instant('core.h5p.download'), + copyrights: this.translate.instant('core.h5p.copyright'), + embed: this.translate.instant('core.h5p.embed'), + size: this.translate.instant('core.h5p.size'), + showAdvanced: this.translate.instant('core.h5p.showadvanced'), + hideAdvanced: this.translate.instant('core.h5p.hideadvanced'), + advancedHelp: this.translate.instant('core.h5p.resizescript'), + copyrightInformation: this.translate.instant('core.h5p.copyright'), + close: this.translate.instant('core.h5p.close'), + title: this.translate.instant('core.h5p.title'), + author: this.translate.instant('core.h5p.author'), + year: this.translate.instant('core.h5p.year'), + source: this.translate.instant('core.h5p.source'), + license: this.translate.instant('core.h5p.license'), + thumbnail: this.translate.instant('core.h5p.thumbnail'), + noCopyrights: this.translate.instant('core.h5p.nocopyright'), + reuse: this.translate.instant('core.h5p.reuse'), + reuseContent: this.translate.instant('core.h5p.reuseContent'), + reuseDescription: this.translate.instant('core.h5p.reuseDescription'), + downloadDescription: this.translate.instant('core.h5p.downloadtitle'), + copyrightsDescription: this.translate.instant('core.h5p.copyrighttitle'), + embedDescription: this.translate.instant('core.h5p.embedtitle'), + h5pDescription: this.translate.instant('core.h5p.h5ptitle'), + contentChanged: this.translate.instant('core.h5p.contentchanged'), + startingOver: this.translate.instant('core.h5p.startingover'), + by: this.translate.instant('core.h5p.by'), + showMore: this.translate.instant('core.h5p.showmore'), + showLess: this.translate.instant('core.h5p.showless'), + subLevel: this.translate.instant('core.h5p.sublevel'), + confirmDialogHeader: this.translate.instant('core.h5p.confirmdialogheader'), + confirmDialogBody: this.translate.instant('core.h5p.confirmdialogbody'), + cancelLabel: this.translate.instant('core.h5p.cancellabel'), + confirmLabel: this.translate.instant('core.h5p.confirmlabel'), + licenseU: this.translate.instant('core.h5p.undisclosed'), + licenseCCBY: this.translate.instant('core.h5p.ccattribution'), + licenseCCBYSA: this.translate.instant('core.h5p.ccattributionsa'), + licenseCCBYND: this.translate.instant('core.h5p.ccattributionnd'), + licenseCCBYNC: this.translate.instant('core.h5p.ccattributionnc'), + licenseCCBYNCSA: this.translate.instant('core.h5p.ccattributionncsa'), + licenseCCBYNCND: this.translate.instant('core.h5p.ccattributionncnd'), + licenseCC40: this.translate.instant('core.h5p.licenseCC40'), + licenseCC30: this.translate.instant('core.h5p.licenseCC30'), + licenseCC25: this.translate.instant('core.h5p.licenseCC25'), + licenseCC20: this.translate.instant('core.h5p.licenseCC20'), + licenseCC10: this.translate.instant('core.h5p.licenseCC10'), + licenseGPL: this.translate.instant('core.h5p.licenseGPL'), + licenseV3: this.translate.instant('core.h5p.licenseV3'), + licenseV2: this.translate.instant('core.h5p.licenseV2'), + licenseV1: this.translate.instant('core.h5p.licenseV1'), + licensePD: this.translate.instant('core.h5p.pd'), + licenseCC010: this.translate.instant('core.h5p.licenseCC010'), + licensePDM: this.translate.instant('core.h5p.pdm'), + licenseC: this.translate.instant('core.h5p.copyrightstring'), + contentType: this.translate.instant('core.h5p.contenttype'), + licenseExtras: this.translate.instant('core.h5p.licenseextras'), + changes: this.translate.instant('core.h5p.changelog'), + contentCopied: this.translate.instant('core.h5p.contentCopied'), + connectionLost: this.translate.instant('core.h5p.connectionLost'), + connectionReestablished: this.translate.instant('core.h5p.connectionReestablished'), + resubmitScores: this.translate.instant('core.h5p.resubmitScores'), + offlineDialogHeader: this.translate.instant('core.h5p.offlineDialogHeader'), + offlineDialogBody: this.translate.instant('core.h5p.offlineDialogBody'), + offlineDialogRetryMessage: this.translate.instant('core.h5p.offlineDialogRetryMessage'), + offlineDialogRetryButtonLabel: this.translate.instant('core.h5p.offlineDialogRetryButtonLabel'), + offlineSuccessfulSubmit: this.translate.instant('core.h5p.offlineSuccessfulSubmit'), + }; + } + /** * Convert list of library parameter values to csv. * @@ -68,4 +338,71 @@ export class CoreH5PUtilsProvider { return ''; } + + /** + * Convert strings of text into simple kebab case slugs. Based on H5PCore::slugify. + * + * @param input The string to slugify. + * @return Slugified text. + */ + slugify(input: string): string { + input = input || ''; + + input = input.toLowerCase(); + + // Replace common chars. + let newInput = ''; + for (let i = 0; i < input.length; i++) { + const char = input[i]; + + newInput += this.SLUGIFY_MAP[char] || char; + } + + // Replace everything else. + newInput = newInput.replace(/[^a-z0-9]/g, '-'); + + // Prevent double hyphen + newInput = newInput.replace(/-{2,}/g, '-'); + + // Prevent hyphen in beginning or end. + newInput = newInput.replace(/(^-+|-+$)/g, ''); + + // Prevent too long slug. + if (newInput.length > 91) { + newInput = newInput.substr(0, 92); + } + + // Prevent empty slug + if (newInput === '') { + newInput = 'interactive'; + } + + return newInput; + } + + /** + * Determine if params contain any match. + * + * @param params Parameters. + * @param pattern Regular expression to identify pattern. + * @return True if params matches pattern. + */ + textAddonMatches(params: any, pattern: string): boolean { + + if (typeof params == 'string') { + if (params.match(pattern)) { + return true; + } + } else if (typeof params == 'object') { + for (const key in params) { + const value = params[key]; + + if (this.textAddonMatches(value, pattern)) { + return true; + } + } + } + + return false; + } } diff --git a/src/providers/file.ts b/src/providers/file.ts index dd48837c3..915ac5a36 100644 --- a/src/providers/file.ts +++ b/src/providers/file.ts @@ -1239,4 +1239,19 @@ export class CoreFileProvider { isFileInAppFolder(path: string): boolean { return path.indexOf(this.basePath) != -1; } + + /** + * Get the full path to the www folder at runtime. + * + * @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; + } }