From 7b4608cb4d7319bfe5602681ac25c409e178d3f5 Mon Sep 17 00:00:00 2001 From: Manav Aggarwal Date: Mon, 12 Jun 2023 22:42:15 +0530 Subject: [PATCH 1/5] - add jszip yarn package; - implement code to extract .h5p files metadata; --- .../frontend/shared/vuex/file/utils.js | 75 +++++++++++++++---- package.json | 1 + yarn.lock | 10 +++ 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/vuex/file/utils.js b/contentcuration/contentcuration/frontend/shared/vuex/file/utils.js index 97970945e1..e650eedc0b 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,42 @@ export function storageUrl(checksum, file_format) { return `/content/storage/${checksum[0]}/${checksum[1]}/${checksum}.${file_format}`; } +export async function getH5PMetadata(fileInput, metadata) { + // const file = fileInput.files[0]; + // console.log(typeof(files), file); + const zip = new JSZip(); + zip + .loadAsync(fileInput) + .then(function(zip) { + const h5pJson = zip.file('h5p.json'); + // console.log(h5pJson); + 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']; + } + }) + .catch(function(error) { + console.error('Error loading or extracting H5P file:', error); + }); + return metadata; +} + /** * @param {{name: String, preset: String}} file * @param {String|null} preset @@ -85,24 +124,32 @@ 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, metadata); + console.log(metadata); 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" From 3a2b734dcdc59c53ede56f5baf64364a3bbee1fb Mon Sep 17 00:00:00 2001 From: Manav Aggarwal Date: Fri, 23 Jun 2023 00:47:38 +0530 Subject: [PATCH 2/5] - [add] the extracted metadata from .h5p CP to contentNode --- .../frontend/channelEdit/components/edit/EditModal.vue | 10 ++++++++-- .../frontend/shared/vuex/file/actions.js | 1 + .../contentcuration/frontend/shared/vuex/file/utils.js | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index e3570dbc69..7ba1d017ff 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -489,13 +489,19 @@ }, createNodesFromUploads(fileUploads) { fileUploads.forEach((file, index) => { - const title = file.original_filename + let title = file.original_filename .split('.') .slice(0, -1) .join('.'); + if (title === undefined) { + title = file.metadata.title; + } + const language = file.metadata.language !== undefined ? file.metadata.language : null; + const author = file.metadata.authors !== undefined ? file.metadata.author : null; + const license = file.metadata.license !== undefined ? file.metadata.license : null; this.createNode( FormatPresets.has(file.preset) && FormatPresets.get(file.preset).kind_id, - { title } + { title, language, author, license } ).then(newNodeId => { if (index === 0) { this.selected = [newNodeId]; 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 e650eedc0b..d6e04cfe49 100644 --- a/contentcuration/contentcuration/frontend/shared/vuex/file/utils.js +++ b/contentcuration/contentcuration/frontend/shared/vuex/file/utils.js @@ -82,7 +82,7 @@ export async function getH5PMetadata(fileInput, metadata) { .then(function(h5pContent) { const data = JSON.parse(h5pContent); if (Object.prototype.hasOwnProperty.call(data, 'title')) { - metadata.Title = data['title']; + metadata.title = data['title']; } if (Object.prototype.hasOwnProperty.call(data, 'language') && data['language'] !== 'und') { metadata.language = data['language']; From cef06b0f7bad031d63d92806ffb70af72db1fde7 Mon Sep 17 00:00:00 2001 From: Manav Aggarwal Date: Sat, 8 Jul 2023 00:53:23 +0530 Subject: [PATCH 3/5] - [refactor] code - override file title --- .../channelEdit/components/edit/EditModal.vue | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index 7ba1d017ff..60a6f7e45b 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -489,19 +489,19 @@ }, createNodesFromUploads(fileUploads) { fileUploads.forEach((file, index) => { - let title = file.original_filename - .split('.') - .slice(0, -1) - .join('.'); - if (title === undefined) { + let title; + if (file.metadata.title) { title = file.metadata.title; + } else { + title = file.original_filename + .split('.') + .slice(0, -1) + .join('.'); } - const language = file.metadata.language !== undefined ? file.metadata.language : null; - const author = file.metadata.authors !== undefined ? file.metadata.author : null; - const license = file.metadata.license !== undefined ? file.metadata.license : null; + console.log(file.metadata); this.createNode( FormatPresets.has(file.preset) && FormatPresets.get(file.preset).kind_id, - { title, language, author, license } + { title, ...file.metadata } ).then(newNodeId => { if (index === 0) { this.selected = [newNodeId]; From 04c749b4a04366e64f4348693c54fd368c471c0b Mon Sep 17 00:00:00 2001 From: Manav Aggarwal Date: Sun, 6 Aug 2023 22:39:30 +0530 Subject: [PATCH 4/5] - [remove] unwanted logs --- .../channelEdit/components/edit/EditModal.vue | 1 - .../frontend/shared/vuex/file/utils.js | 18 +++++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index 60a6f7e45b..995072d190 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -498,7 +498,6 @@ .slice(0, -1) .join('.'); } - console.log(file.metadata); this.createNode( FormatPresets.has(file.preset) && FormatPresets.get(file.preset).kind_id, { title, ...file.metadata } diff --git a/contentcuration/contentcuration/frontend/shared/vuex/file/utils.js b/contentcuration/contentcuration/frontend/shared/vuex/file/utils.js index d6e04cfe49..ae670e5f45 100644 --- a/contentcuration/contentcuration/frontend/shared/vuex/file/utils.js +++ b/contentcuration/contentcuration/frontend/shared/vuex/file/utils.js @@ -19,7 +19,7 @@ export function getHash(file) { const spark = new SparkMD5.ArrayBuffer(); let currentChunk = 0; const chunks = Math.ceil(file.size / CHUNK_SIZE); - fileReader.onload = function(e) { + fileReader.onload = function (e) { spark.append(e.target.result); currentChunk++; @@ -65,21 +65,18 @@ export function storageUrl(checksum, file_format) { } export async function getH5PMetadata(fileInput, metadata) { - // const file = fileInput.files[0]; - // console.log(typeof(files), file); const zip = new JSZip(); - zip + return zip .loadAsync(fileInput) - .then(function(zip) { + .then(function (zip) { const h5pJson = zip.file('h5p.json'); - // console.log(h5pJson); if (h5pJson) { return h5pJson.async('text'); } else { throw new Error('h5p.json not found in the H5P file.'); } }) - .then(function(h5pContent) { + .then(function (h5pContent) { const data = JSON.parse(h5pContent); if (Object.prototype.hasOwnProperty.call(data, 'title')) { metadata.title = data['title']; @@ -93,11 +90,11 @@ export async function getH5PMetadata(fileInput, metadata) { if (Object.prototype.hasOwnProperty.call(data, 'license')) { metadata.license = data['license']; } + return metadata }) - .catch(function(error) { - console.error('Error loading or extracting H5P file:', error); + .catch(function (error) { + return error }); - return metadata; } /** @@ -132,7 +129,6 @@ export function extractMetadata(file, preset = null) { return new Promise(resolve => { if (isH5P) { getH5PMetadata(file, metadata); - console.log(metadata); resolve(metadata); } else { const mediaElement = document.createElement(isVideo ? 'video' : 'audio'); From 3f68c510ee1caa83da74191e58729e075e2a1983 Mon Sep 17 00:00:00 2001 From: Manav Aggarwal Date: Sun, 6 Aug 2023 22:42:52 +0530 Subject: [PATCH 5/5] - [add] test case for H5P content file extract metadata - [lint] fixes --- .../shared/vuex/file/__tests__/module.spec.js | 71 +++++++++++++++++++ .../frontend/shared/vuex/file/utils.js | 19 ++--- 2 files changed, 82 insertions(+), 8 deletions(-) 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/utils.js b/contentcuration/contentcuration/frontend/shared/vuex/file/utils.js index ae670e5f45..ed67a34bc0 100644 --- a/contentcuration/contentcuration/frontend/shared/vuex/file/utils.js +++ b/contentcuration/contentcuration/frontend/shared/vuex/file/utils.js @@ -19,7 +19,7 @@ export function getHash(file) { const spark = new SparkMD5.ArrayBuffer(); let currentChunk = 0; const chunks = Math.ceil(file.size / CHUNK_SIZE); - fileReader.onload = function (e) { + fileReader.onload = function(e) { spark.append(e.target.result); currentChunk++; @@ -64,11 +64,12 @@ export function storageUrl(checksum, file_format) { return `/content/storage/${checksum[0]}/${checksum[1]}/${checksum}.${file_format}`; } -export async function getH5PMetadata(fileInput, metadata) { +export async function getH5PMetadata(fileInput) { const zip = new JSZip(); + const metadata = {}; return zip .loadAsync(fileInput) - .then(function (zip) { + .then(function(zip) { const h5pJson = zip.file('h5p.json'); if (h5pJson) { return h5pJson.async('text'); @@ -76,7 +77,7 @@ export async function getH5PMetadata(fileInput, metadata) { throw new Error('h5p.json not found in the H5P file.'); } }) - .then(function (h5pContent) { + .then(function(h5pContent) { const data = JSON.parse(h5pContent); if (Object.prototype.hasOwnProperty.call(data, 'title')) { metadata.title = data['title']; @@ -90,10 +91,10 @@ export async function getH5PMetadata(fileInput, metadata) { if (Object.prototype.hasOwnProperty.call(data, 'license')) { metadata.license = data['license']; } - return metadata + return metadata; }) - .catch(function (error) { - return error + .catch(function(error) { + return error; }); } @@ -128,7 +129,9 @@ export function extractMetadata(file, preset = null) { return new Promise(resolve => { if (isH5P) { - getH5PMetadata(file, metadata); + getH5PMetadata(file).then(data => { + if (data.constructor !== Error) Object.assign(metadata, ...data); + }); resolve(metadata); } else { const mediaElement = document.createElement(isVideo ? 'video' : 'audio');