diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/files/ContentRenderer.vue b/contentcuration/contentcuration/frontend/channelEdit/views/files/ContentRenderer.vue index 3f8088b3cc..ca35bafa50 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/files/ContentRenderer.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/files/ContentRenderer.vue @@ -36,8 +36,9 @@ @@ -67,6 +68,9 @@ import uniqBy from 'lodash/uniqBy'; import sortBy from 'lodash/sortBy'; import { mapGetters } from 'vuex'; + // eslint-disable-next-line + import Hashi from 'hashi'; + import urls from './hashi/urls'; import EpubRenderer from './EpubRenderer'; import FileStatus from 'shared/views/files/FileStatus'; @@ -102,6 +106,9 @@ file() { return this.getContentNodeFileById(this.nodeId, this.fileId); }, + rooturl() { + return urls.hashi(); + }, supplementaryFiles() { let files = this.nodeId ? this.getContentNodeFiles(this.nodeId) : []; return files.filter(f => f.preset.supplementary); @@ -122,15 +129,15 @@ isHTML() { return this.file.file_format === 'zip'; }, + isH5P() { + return this.defaultFile.extension === 'h5p'; + }, isPDF() { return this.file.file_format === 'pdf'; }, isEpub() { return this.file.file_format === 'epub'; }, - htmlPath() { - return `/zipcontent/${this.file.checksum}.${this.file.file_format}`; - }, src() { return this.file && this.file.url; }, @@ -140,6 +147,47 @@ this.loading = Boolean(newFileId); }, }, + mounted() { + const now = Date.now(); + this.hashi = new Hashi({ iframe: this.$refs.iframe, now }); + this.hashi.onStateUpdate(data => { + this.$emit('updateContentState', data); + }); + this.hashi.on('navigateTo', message => { + this.$emit('navigateTo', message); + }); + this.hashi.on(this.hashi.events.RESIZE, scrollHeight => { + this.iframeHeight = scrollHeight; + }); + this.hashi.on(this.hashi.events.LOADING, loading => { + this.loading = loading; + }); + this.hashi.on(this.hashi.events.ERROR, err => { + this.loading = false; + this.$emit('error', err); + }); + let storageUrl = this.defaultFile.storage_url; + if (!this.isH5P) { + // In the case that this is being routed via a remote URL + // ensure we preserve that for the zip endpoint. + const query = this.defaultFile.storage_url.split('?')[1]; + storageUrl = urls.zipContentUrl( + this.defaultFile.checksum, + this.defaultFile.extension, + this.entry + ); + if (query) { + storageUrl += '?' + query; + } + } + + this.hashi.initialize( + (this.extraFields && this.extraFields.contentState) || {}, + this.userData || {}, + storageUrl, + this.defaultFile.checksum + ); + }, $trs: { noFileText: 'Select a file to preview', previewNotSupported: 'Preview unavailable', diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/files/hashi/baseShim.js b/contentcuration/contentcuration/frontend/channelEdit/views/files/hashi/baseShim.js new file mode 100644 index 0000000000..23bae7ff05 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/views/files/hashi/baseShim.js @@ -0,0 +1,38 @@ +import { events } from './hashiBase'; + +export default class BaseShim { + constructor(mediator) { + this.__mediator = mediator; + this.events = Object.assign({}, events); + this.__nowDiff = 0; + this.__setNowDiff = this.__setNowDiff.bind(this); + this.on(this.events.NOW, this.__setNowDiff); + } + + setData(data) { + this.__setData(data); + this.stateUpdated(); + } + + sendMessage(event, data) { + this.__mediator.sendMessage({ nameSpace: this.nameSpace, event, data }); + } + + on(event, callback) { + if (!Object.values(this.events).includes(event)) { + throw ReferenceError(`${event} is not a valid event name for ${this.nameSpace}`); + } + this.__mediator.registerMessageHandler({ nameSpace: this.nameSpace, event, callback }); + } + + off(event, callback) { + if (!Object.values(this.events).includes(event)) { + throw ReferenceError(`${event} is not a valid event name for ${this.nameSpace}`); + } + this.__mediator.removeMessageHandler({ nameSpace: this.nameSpace, event, callback }); + } + + stateUpdated() { + this.sendMessage(this.events.STATEUPDATE, this.data); + } +} diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/files/hashi/hashiBase.js b/contentcuration/contentcuration/frontend/channelEdit/views/files/hashi/hashiBase.js new file mode 100644 index 0000000000..587aa75101 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/views/files/hashi/hashiBase.js @@ -0,0 +1,43 @@ +export const events = { + READYCHECK: 'readycheck', + MAINREADY: 'mainready', + IFRAMEREADY: 'iframeready', + STATEUPDATE: 'stateupdate', + USERDATAUPDATE: 'userdataupdate', + DATAREQUESTED: 'datarequested', + COLLECTIONREQUESTED: 'collectionrequested', + COLLECTIONPAGEREQUESTED: 'collectionpagerequested', + MODELREQUESTED: 'modelrequested', + SEARCHRESULTREQUESTED: 'searchresultrequested', + DATARETURNED: 'datareturned', + KOLIBRIDATARETURNED: 'kolibridatareturned', + NAVIGATETO: 'navigateTo', + CONTEXT: 'context', + THEMECHANGED: 'themechanged', + KOLIBRIVERSIONREQUESTED: 'kolibriversionrequested', + CHANNELMETADATAREQUESTED: 'channelmetadatarequested', + CHANNELFILTEROPTIONSREQUESTED: 'channelfilteroptionsrequested', + RANDOMCOLLECTIONREQUESTED: 'randomcollectionrequested', + NOW: 'now', + RESIZE: 'resize', + LOADING: 'loading', + ERROR: 'error', +}; + +export const DataTypes = { + MODEL: 'Model', + SEARCHRESULT: 'SearchResult', + COLLECTION: 'Collection', + COLLECTIONPAGE: 'CollectionPage', + KOLIBRIVERSION: 'KolibriVersion', + CHANNELMETADATA: 'ChannelMetadata', + CHANNELFILTEROPTIONS: 'ChannelFilterOptions', + RANDOMCOLLECTION: 'RandomCollection', +}; + +export const MessageStatuses = { + FAILURE: 'failure', + SUCCESS: 'success', +}; + +export const nameSpace = 'hashi'; diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/files/hashi/kolibri.js b/contentcuration/contentcuration/frontend/channelEdit/views/files/hashi/kolibri.js new file mode 100644 index 0000000000..fa59d82614 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/views/files/hashi/kolibri.js @@ -0,0 +1,305 @@ +/** + * This class offers an API interface for interacting directly with the Kolibri app + * that the HTML5 app is embedded within + */ +import BaseShim from './baseShim'; +import Mediator from './mediator'; +import { events, nameSpace, DataTypes } from './hashiBase'; + +/** + * Type definition for Language metadata + * @typedef {Object} Language + * @property {string} id - an IETF language tag + * @property {string} lang_code - the ISO 639‑1 language code + * @property {string} lang_subcode - the regional identifier + * @property {string} lang_name - the name of the language in that language + * @property {('ltr'|'rtl'|)} lang_direction - Direction of the language's script, + * top to bottom is not supported currently + */ + +/** + * Type definition for ContentNode metadata + * @typedef {Object} ContentNode + * @property {string} id - unique id of the ContentNode + * @property {string} channel_id - unique channel_id of the channel that the ContentNode is in + * @property {string} content_id - identifier that is common across all instances of this resource + * @property {string} title - A title that summarizes this ContentNode for the user + * @property {string} description - detailed description of the ContentNode + * @property {string} author - author of the ContentNode + * @property {string} thumbnail_url - URL for the thumbnail for this ContentNode, + * this may be any valid URL format including base64 encoded or blob URL + * @property {boolean} available - Whether the ContentNode has all necessary files for rendering + * @property {boolean} coach_content - Whether the ContentNode is intended only for coach users + * @property {Language} lang - The primary language of the ContentNode + * @property {string} license_description - The description of the license, which may be localized + * @property {string} license_name - The human readable name of the license, localized + * @property {string} license_owner - The name of the person or organization that holds copyright + * @property {number} num_coach_contents - Number of coach contents that are descendants of this + * @property {string} parent - The unique id of the parent of this ContentNode + * @property {number} sort_order - The order of display for this node in its channel + * if depth recursion was not deep enough + */ + +/** + * Type definition for pagination more object + * @typedef {Object} MoreObject + * @property {string} cursor - the cursor object to request more + */ + +/** + * Type definition for pagination object + * @typedef {Object} PageResult + * @property {MoreObject} more - the context object to query more + * @property {number} maxResults - the maximum number of nodes per request + * @property {ContentNode[]} results - the array of ContentNodes for this page + */ + +/** + * Type definition for channel metadata object + * @typedef {Object} ChannelMetadata + * @property {string} id - the channel id + * @property {string} title - the channel title + * @property {string} description - the channel description + * @property {string} thumbnail - the channel thumbnail + */ + +/** + * Type definition for channel filter options object + * @typedef {Object} ChannelFilterOptions + * @property {string[]} availableAuthors - list of authors on this channel + * @property {string[]} availableTags - list of tags in this channel + * @property {string[]} availableKinds - list of kinds in this channel + */ + +/** + * Type definition for Theme options + * properties TBD + * @typedef {Object} Theme + */ + +/** + * Type definition for NavigationContext + * This can have arbitrary properties as defined + * by the navigating app that it uses to resume its state + * Should be able to be encoded down to <1600 characters using + * an encoding function something like 'encode context' above + * @typedef {Object} NavigationContext + * @property {string} node_id - The current node_id that is being displayed, + * custom apps should handle this as it may be used to + * generate links externally to jump to this state + */ + +export default class Kolibri extends BaseShim { + constructor(mediator) { + super(mediator); + this.data = {}; + this.nameSpace = 'kolibri'; + this.mediator = new Mediator(window.parent); + } + + iframeInitialize(contentWindow) { + this.__setShimInterface(); + Object.defineProperty(contentWindow, this.nameSpace, { + value: this.shim, + configurable: true, + }); + } + + __setShimInterface() { + const self = this; + + class Shim { + /* + * Method to query contentnodes from Kolibri and return + * an array of matching metadata + * @param {Object} options - The different options to filter by + * @param {string=} options.parent - id of the parent node to filter by, or 'self' + * @param {string=} options.descendantOf - id of the root node to filter + * by, to show all descendants, not just direct children + * @param {string[]} options.ids - an array of ids to filter by + * @param {number} [options.maxResults=50] - the maximum number of nodes per request + * @param {string[]} options.kinds - an array of kinds to filer by + * @param {string[]} options.authors - an array of authors to filter by + * @param {string[]} options.tags - an array of tags to filter by + * @param {boolean} options.onlyTopics - set to true to query only topic nodes + * @param {boolean} options.onlyContent - set to true to query only content nodes + * @param {boolean} [options.limitToChannel=true] - true to limit the + * results to the topic channel + * @return {Promise} - a Promise that resolves to an array of ContentNodes + */ + getContentByFilter(options) { + return self.mediator.sendMessageAwaitReply({ + event: events.DATAREQUESTED, + data: { options, dataType: DataTypes.COLLECTION }, + nameSpace, + }); + } + /* + * Method to query next page of contentnodes from Kolibri and return + * an array + * @param {MoreObject} options - A more object returned in a call to getContentByFilter + * @return {Promise} - a Promise that resolves to an array of ContentNodes + */ + getContentPage(options) { + return self.mediator.sendMessageAwaitReply({ + event: events.DATAREQUESTED, + data: { options, dataType: DataTypes.COLLECTIONPAGE }, + nameSpace, + }); + } + /* + * Method to query a single contentnode from Kolibri and return + * a metadata object + * @param {string} id - id of the ContentNode + * @return {Promise} - a Promise that resolves to a ContentNode + */ + getContentById(id) { + return self.mediator.sendMessageAwaitReply({ + event: events.DATAREQUESTED, + data: { id, dataType: DataTypes.MODEL }, + nameSpace, + }); + } + /* + * Method to search for contentnodes on Kolibri and return + * an array of matching metadata + * @param {Object} options - The different options to search by + * @param {string=} options.keyword - search term for key word search + * @param {number} [options.maxResults=50] - the maximum number of nodes per request + * @param {boolean} [options.limitToChannel=true] - true to limit the + * results to the topic channel + * @return {Promise} - a Promise that resolves to an array of ContentNodes + */ + searchContent(options) { + return self.mediator.sendMessageAwaitReply({ + event: events.DATAREQUESTED, + data: { options, dataType: DataTypes.SEARCHRESULT }, + nameSpace, + }); + } + + /* + * Method to set a default theme for any content rendering initiated by this app + * @param {Theme} options - The different options for custom themeing + * @param {string} options.appBarColor - Color for app bar atop the renderer + * @param {string} options.textColor - Color for the text or icon + * @param {string} [options.backdropColor] - Color for modal backdrop + * @param {string} [options.backgroundColor] - Color for modal background + * @return {Promise} - a Promise that resolves when the theme has been applied + */ + themeRenderer(options) { + return self.mediator.sendMessageAwaitReply({ + event: events.THEMECHANGED, + data: options, + nameSpace, + }); + } + + /* + * Method to allow navigation to or rendering of a specific node + * has optional parameter context that can update the URL for a custom context. + * When this is called for a resource node in the custom navigation context + * this will launch a renderer overlay to maintain the current state, and update the + * query parameters for the URL of the custom context to indicate the change + * If called for a topic in a custom context or outside of a custom context + * this will simply prompt navigation to that node in Kolibri. + * @param {string} nodeId - id of the parent node to navigate to + * @param {NavigationContext=} context - optional context describing the state update + * if node_id is missing from the context, it will be automatically filled in by this method + * @return {Promise} - a Promise that resolves when the navigation has completed + */ + navigateTo(nodeId, context) { + return self.mediator.sendMessageAwaitReply({ + event: events.NAVIGATETO, + data: { nodeId, context }, + nameSpace, + }); + } + + /* + * Method to allow updating of stored state in the URL + * @param {NavigationContext} context - context describing the state update + * @return {Promise} - a Promise that resolves when the context has been updated + */ + updateContext(context) { + return self.mediator.sendMessageAwaitReply({ + event: events.CONTEXT, + data: { context }, + nameSpace, + }); + } + + /* + * Method to request the current context state + * @return {Promise} - a Promise that resolves + * when the context has been updated + */ + getContext() { + return self.mediator.sendMessageAwaitReply({ + event: events.CONTEXT, + data: {}, + nameSpace, + }); + } + + /* + * Method to return the current version of Kolibri and hence the API available. + * @return {Promise} - A version string + */ + getVersion() { + return self.mediator.sendMessageAwaitReply({ + event: events.DATAREQUESTED, + data: { dataType: DataTypes.KOLIBRIVERSION }, + nameSpace, + }); + } + + /* + * Method to query channel metadata from Kolibri + * @return {Promise} - a Promise that resolves to ChannelMetadata + */ + getChannelMetadata() { + return self.mediator.sendMessageAwaitReply({ + event: events.DATAREQUESTED, + data: { dataType: DataTypes.CHANNELMETADATA }, + nameSpace, + }); + } + + /* + * Method to query channel filter options from Kolibri + * @return {Promise} - a Promise that resolves to ChannelFilterOptions + */ + getChannelFilterOptions() { + return self.mediator.sendMessageAwaitReply({ + event: events.DATAREQUESTED, + data: { dataType: DataTypes.CHANNELFILTEROPTIONS }, + nameSpace, + }); + } + + /* + * Method to query random contentnodes from Kolibri and return an array + * of matching metadata + * @param {Object} options - The different options to filter by + * @param {string=} options.parent - id of the parent node to filter by, or 'self' + * @param {number} [options.maxResults=10] - the maximum number of nodes per request + * @param {string[]} options.kinds - an array of kinds to filer by + * @param {boolean} options.onlyContent - set to true to query only content nodes + * @param {boolean} [options.limitToChannel=true] - true to limit the + * results to the topic channel + * @return {Promise} - a Promise that resolves to an array of ContentNodes + */ + getRandomNodes(options) { + return self.mediator.sendMessageAwaitReply({ + event: events.DATAREQUESTED, + data: { options, dataType: DataTypes.RANDOMCOLLECTION }, + nameSpace, + }); + } + } + + this.shim = new Shim(); + return this.shim; + } +} diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/files/hashi/mainClient.js b/contentcuration/contentcuration/frontend/channelEdit/views/files/hashi/mainClient.js new file mode 100644 index 0000000000..22b6372859 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/views/files/hashi/mainClient.js @@ -0,0 +1,124 @@ +import Mediator from './mediator'; +import { events, nameSpace, DataTypes } from './hashiBase'; +import Kolibri from './kolibri'; + +/* + * This is the main entry point for interacting with the Hashi library. + * Import this client in order to wrap an iframe that has an instance of + * the 'SandboxEnvironment' class (found inside iframeClient.js) inside of it. + * When an iframe has been wrapped, then this class can be initialized to set initial + * data, and allow the iframe to setup its own environment and start running + * a contained HTML5 app. + */ +export default class MainClient { + constructor({ iframe, now } = {}) { + this.events = events; + this.iframe = iframe; + this.mediator = new Mediator(this.iframe.contentWindow); + this.storage = {}; + this.kolibri = new Kolibri(this.mediator); + this.now = now; + this.ready = false; + this.contentNamespace = null; + this.startUrl = null; + } + initialize(contentState, userData, startUrl, contentNamespace) { + /* + * userData should be an object with the following keys, all optional: + * userId: , + * userFullName: , + * progress: , + * complete: , + * timeSpent: