diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index e3570dbc69..995072d190 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -489,13 +489,18 @@ }, createNodesFromUploads(fileUploads) { fileUploads.forEach((file, index) => { - const title = file.original_filename - .split('.') - .slice(0, -1) - .join('.'); + let title; + if (file.metadata.title) { + title = file.metadata.title; + } else { + title = file.original_filename + .split('.') + .slice(0, -1) + .join('.'); + } this.createNode( FormatPresets.has(file.preset) && FormatPresets.get(file.preset).kind_id, - { title } + { title, ...file.metadata } ).then(newNodeId => { if (index === 0) { this.selected = [newNodeId]; diff --git a/contentcuration/contentcuration/frontend/shared/vuex/file/__tests__/module.spec.js b/contentcuration/contentcuration/frontend/shared/vuex/file/__tests__/module.spec.js index 9a43b87d33..cddc6cfc53 100644 --- a/contentcuration/contentcuration/frontend/shared/vuex/file/__tests__/module.spec.js +++ b/contentcuration/contentcuration/frontend/shared/vuex/file/__tests__/module.spec.js @@ -1,3 +1,5 @@ +import JSZip from 'jszip'; +import { getH5PMetadata } from '../utils'; import storeFactory from 'shared/vuex/baseStore'; import { File, injectVuexStore } from 'shared/data/resources'; import client from 'shared/client'; @@ -19,6 +21,27 @@ const testFile = { const userId = 'some user'; +function get_metadata_file(data) { + const manifest = { + h5p: '1.0', + mainLibrary: 'content', + libraries: [ + { + machineName: 'content', + majorVersion: 1, + minorVersion: 0, + }, + ], + content: { + library: 'content', + }, + ...data, + }; + const manifestBlob = new Blob([JSON.stringify(manifest, null, 2)], { type: 'application/json' }); + const manifestFile = new global.File([manifestBlob], 'h5p.json', { type: 'application/json' }); + return manifestFile; +} + describe('file store', () => { let store; let id; @@ -122,5 +145,53 @@ describe('file store', () => { }); }); }); + describe('H5P content file extract metadata', () => { + it('getH5PMetadata should check for h5p.json file', () => { + const zip = new JSZip(); + return zip.generateAsync({ type: 'blob' }).then(async function(h5pBlob) { + await expect(Promise.resolve(getH5PMetadata(h5pBlob))).resolves.toThrowError( + 'h5p.json not found in the H5P file.' + ); + }); + }); + it('getH5PMetadata should exract metadata from h5p.json', async () => { + const manifestFile = get_metadata_file({ title: 'Test file' }); + const zip = new JSZip(); + zip.file('h5p.json', manifestFile); + await zip.generateAsync({ type: 'blob' }).then(async function(h5pBlob) { + await expect(Promise.resolve(getH5PMetadata(h5pBlob))).resolves.toEqual({ + title: 'Test file', + }); + }); + }); + it('getH5PMetadata should not extract und language', async () => { + const manifestFile = get_metadata_file({ title: 'Test file', language: 'und' }); + const zip = new JSZip(); + zip.file('h5p.json', manifestFile); + await zip.generateAsync({ type: 'blob' }).then(async function(h5pBlob) { + await expect(Promise.resolve(getH5PMetadata(h5pBlob))).resolves.toEqual({ + title: 'Test file', + }); + }); + }); + it('getH5PMetadata should exract metadata from h5p.json', async () => { + const manifestFile = get_metadata_file({ + title: 'Test file', + language: 'en', + authors: 'author1', + license: 'license1', + }); + const zip = new JSZip(); + zip.file('h5p.json', manifestFile); + await zip.generateAsync({ type: 'blob' }).then(async function(h5pBlob) { + await expect(Promise.resolve(getH5PMetadata(h5pBlob))).resolves.toEqual({ + title: 'Test file', + language: 'en', + author: 'author1', + license: 'license1', + }); + }); + }); + }); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/vuex/file/actions.js b/contentcuration/contentcuration/frontend/shared/vuex/file/actions.js index 3225e58099..778ebec9fa 100644 --- a/contentcuration/contentcuration/frontend/shared/vuex/file/actions.js +++ b/contentcuration/contentcuration/frontend/shared/vuex/file/actions.js @@ -198,6 +198,7 @@ export function uploadFile(context, { file, preset = null } = {}) { }); // End get upload url }) .then(data => { + data.file.metadata = metadata; const fileObject = { ...data.file, loaded: 0, diff --git a/contentcuration/contentcuration/frontend/shared/vuex/file/utils.js b/contentcuration/contentcuration/frontend/shared/vuex/file/utils.js index 97970945e1..ed67a34bc0 100644 --- a/contentcuration/contentcuration/frontend/shared/vuex/file/utils.js +++ b/contentcuration/contentcuration/frontend/shared/vuex/file/utils.js @@ -1,4 +1,5 @@ import SparkMD5 from 'spark-md5'; +import JSZip from 'jszip'; import { FormatPresetsList, FormatPresetsNames } from 'shared/leUtils/FormatPresets'; const BLOB_SLICE = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice; @@ -7,8 +8,10 @@ const MEDIA_PRESETS = [ FormatPresetsNames.AUDIO, FormatPresetsNames.HIGH_RES_VIDEO, FormatPresetsNames.LOW_RES_VIDEO, + FormatPresetsNames.H5P, ]; const VIDEO_PRESETS = [FormatPresetsNames.HIGH_RES_VIDEO, FormatPresetsNames.LOW_RES_VIDEO]; +const H5P_PRESETS = [FormatPresetsNames.H5P]; export function getHash(file) { return new Promise((resolve, reject) => { @@ -61,6 +64,40 @@ export function storageUrl(checksum, file_format) { return `/content/storage/${checksum[0]}/${checksum[1]}/${checksum}.${file_format}`; } +export async function getH5PMetadata(fileInput) { + const zip = new JSZip(); + const metadata = {}; + return zip + .loadAsync(fileInput) + .then(function(zip) { + const h5pJson = zip.file('h5p.json'); + if (h5pJson) { + return h5pJson.async('text'); + } else { + throw new Error('h5p.json not found in the H5P file.'); + } + }) + .then(function(h5pContent) { + const data = JSON.parse(h5pContent); + if (Object.prototype.hasOwnProperty.call(data, 'title')) { + metadata.title = data['title']; + } + if (Object.prototype.hasOwnProperty.call(data, 'language') && data['language'] !== 'und') { + metadata.language = data['language']; + } + if (Object.prototype.hasOwnProperty.call(data, 'authors')) { + metadata.author = data['authors']; + } + if (Object.prototype.hasOwnProperty.call(data, 'license')) { + metadata.license = data['license']; + } + return metadata; + }) + .catch(function(error) { + return error; + }); +} + /** * @param {{name: String, preset: String}} file * @param {String|null} preset @@ -85,24 +122,33 @@ export function extractMetadata(file, preset = null) { return Promise.resolve(metadata); } + const isH5P = H5P_PRESETS.includes(metadata.preset); + // Extract additional media metadata const isVideo = VIDEO_PRESETS.includes(metadata.preset); return new Promise(resolve => { - const mediaElement = document.createElement(isVideo ? 'video' : 'audio'); - // Add a listener to read the metadata once it has loaded. - mediaElement.addEventListener('loadedmetadata', () => { - metadata.duration = Math.floor(mediaElement.duration); - // Override preset based off video resolution - if (isVideo) { - metadata.preset = - mediaElement.videoHeight >= 720 - ? FormatPresetsNames.HIGH_RES_VIDEO - : FormatPresetsNames.LOW_RES_VIDEO; - } + if (isH5P) { + getH5PMetadata(file).then(data => { + if (data.constructor !== Error) Object.assign(metadata, ...data); + }); resolve(metadata); - }); - // Set the src url on the media element - mediaElement.src = URL.createObjectURL(file); + } else { + const mediaElement = document.createElement(isVideo ? 'video' : 'audio'); + // Add a listener to read the metadata once it has loaded. + mediaElement.addEventListener('loadedmetadata', () => { + metadata.duration = Math.floor(mediaElement.duration); + // Override preset based off video resolution + if (isVideo) { + metadata.preset = + mediaElement.videoHeight >= 720 + ? FormatPresetsNames.HIGH_RES_VIDEO + : FormatPresetsNames.LOW_RES_VIDEO; + } + resolve(metadata); + }); + // Set the src url on the media element + mediaElement.src = URL.createObjectURL(file); + } }); } diff --git a/package.json b/package.json index d19c7173cc..cc935f11b9 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "intl": "1.2.5", "jquery": "^2.2.4", "jspdf": "https://github.com/parallax/jsPDF.git#b7a1d8239c596292ce86dafa77f05987bcfa2e6e", + "jszip": "^3.10.1", "kolibri-constants": "^0.1.41", "kolibri-design-system": "https://github.com/learningequality/kolibri-design-system#e9a2ff34716bb6412fe99f835ded5b17345bab94", "lodash": "^4.17.21", diff --git a/yarn.lock b/yarn.lock index 1d65477512..2d19dd5ae9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8967,6 +8967,16 @@ jsprim@^1.2.2: json-schema "0.4.0" verror "1.10.0" +jszip@^3.10.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" + integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g== + dependencies: + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + setimmediate "^1.0.5" + jszip@^3.7.1: version "3.10.0" resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.0.tgz#faf3db2b4b8515425e34effcdbb086750a346061"