diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue index dc55fe2485..4d334b8d35 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditList.vue @@ -7,11 +7,11 @@ class="mb-2 ml-1 mt-0 px-3 py-2" :label="$tr('selectAllLabel')" /> - @@ -22,13 +22,14 @@ + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index 995072d190..4659e64cce 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -71,7 +71,8 @@ @@ -190,13 +191,14 @@ import ResizableNavigationDrawer from 'shared/views/ResizableNavigationDrawer'; import Uploader from 'shared/views/files/Uploader'; import LoadingText from 'shared/views/LoadingText'; - import FormatPresets from 'shared/leUtils/FormatPresets'; + import FormatPresets, { FormatPresetsList } from 'shared/leUtils/FormatPresets'; import OfflineText from 'shared/views/OfflineText'; import ToolBar from 'shared/views/ToolBar'; import BottomBar from 'shared/views/BottomBar'; import FileDropzone from 'shared/views/files/FileDropzone'; import { isNodeComplete } from 'shared/utils/validation'; - import { DELAYED_VALIDATION } from 'shared/constants'; + import { DELAYED_VALIDATION, fileErrors } from 'shared/constants'; + import { File } from 'shared/data/resources'; const CHECK_STORAGE_INTERVAL = 10000; @@ -453,7 +455,7 @@ }, /* Creation actions */ - createNode(kind, payload = {}) { + createNode(kind, payload = {}, parent = this.$route.params.nodeId) { this.enableValidation(this.nodeIds); // Default learning activity on upload if ( @@ -466,7 +468,7 @@ } return this.createContentNode({ kind, - parent: this.$route.params.nodeId, + parent: parent, channel_id: this.currentChannel.id, ...payload, }).then(newNodeId => { @@ -498,18 +500,67 @@ .slice(0, -1) .join('.'); } - this.createNode( - FormatPresets.has(file.preset) && FormatPresets.get(file.preset).kind_id, - { title, ...file.metadata } - ).then(newNodeId => { - if (index === 0) { - this.selected = [newNodeId]; - } - this.updateFile({ - ...file, - contentnode: newNodeId, + if (file.metadata.folders === undefined) { + this.createNode( + FormatPresets.has(file.preset) && FormatPresets.get(file.preset).kind_id, + { title, ...file.metadata } + ).then(newNodeId => { + if (index === 0) { + this.selected = [newNodeId]; + } + this.updateFile({ + ...file, + contentnode: newNodeId, + }); }); - }); + } else if (file.metadata.folders) { + this.createNode('topic', file.metadata).then(newNodeId => { + file.metadata.folders.forEach(folder => { + this.createNode('topic', folder, newNodeId).then(topicNodeId => { + folder.files.forEach(folderFile => { + const extra_fields = {}; + extra_fields['options'] = { entry: folderFile.resourceHref }; + extra_fields['title'] = folderFile.title; + let file_kind = null; + FormatPresetsList.forEach(p => { + if (p.id === file.metadata.preset) { + file_kind = p.kind_id; + } + }); + + this.createNode(file_kind, extra_fields, topicNodeId).then(resourceNodeId => { + return File.uploadUrl({ + checksum: file.checksum, + size: file.file_size, + name: file.original_filename, + file_format: file.file_format, + preset: file.metadata.preset, + }).then(data => { + const fileObject = { + ...data.file, + loaded: 0, + total: file.size, + }; + if (!this.selected.length) { + this.selected = [resourceNodeId]; + } + this.updateFile({ + ...fileObject, + contentnode: resourceNodeId, + }).catch(error => { + let errorType = fileErrors.UPLOAD_FAILED; + if (error.response && error.response.status === 412) { + errorType = fileErrors.NO_STORAGE; + } + return Promise.reject(errorType); + }); + }); + }); + }); + }); + }); + }); + } }); }, updateTitleForPage() { @@ -582,4 +633,4 @@ margin-top: -4px !important; } - + \ No newline at end of file diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/files/ContentRenderer.vue b/contentcuration/contentcuration/frontend/channelEdit/views/files/ContentRenderer.vue index 58faf53ff0..56b6c5c703 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/files/ContentRenderer.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/views/files/ContentRenderer.vue @@ -99,6 +99,10 @@ }, computed: { ...mapGetters('file', ['getContentNodeFileById', 'getContentNodeFiles']), + ...mapGetters('contentNode', ['getContentNode']), + node() { + return this.getContentNode(this.nodeId); + }, file() { return this.getContentNodeFileById(this.nodeId, this.fileId); }, @@ -129,7 +133,9 @@ return this.file.file_format === 'epub'; }, htmlPath() { - return `/zipcontent/${this.file.checksum}.${this.file.file_format}`; + return `/zipcontent/${this.file.checksum}.${this.file.file_format}/${(this.node.options && + this.node.options.entry) || + ''}`; }, src() { return this.file && this.file.url; 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 3f38ceabd8..9af92da859 100644 --- a/contentcuration/contentcuration/frontend/shared/vuex/file/__tests__/module.spec.js +++ b/contentcuration/contentcuration/frontend/shared/vuex/file/__tests__/module.spec.js @@ -1,5 +1,5 @@ import JSZip from 'jszip'; -import { getH5PMetadata } from '../utils'; +import { getH5PMetadata, extractIMSMetadata } from '../utils'; import storeFactory from 'shared/vuex/baseStore'; import { File, injectVuexStore } from 'shared/data/resources'; import client from 'shared/client'; @@ -237,5 +237,289 @@ describe('file store', () => { }); }); }); + describe('IMS content file extract metadata', () => { + it('extractIMSMetadata should check for imsmanifest.xml file', () => { + const zip = new JSZip(); + return zip.generateAsync({ type: 'blob' }).then(async function(IMSBlob) { + await expect(extractIMSMetadata(IMSBlob)).rejects.toThrow( + 'imsmanifest.xml not found in the zip file.' + ); + }); + }); + it('extractIMSMetadata should extract metadata from imsmanifest.xml', async () => { + const manifestContent = ` + + + + + + Test File + + en + + Example of test file + + + + + + + Folder 1 + + Test File1 + + + Test File2 + + + Folder 1 + + Test File1 + + + Test File2 + + + + Folder 2 + + Test File3 + + + Test File4 + + + + + + + Folder 2 + + Test File3 + + + Test File4 + + + + + + + + + + + + + + `; + + const zip = new JSZip(); + zip.file('imsmanifest.xml', manifestContent); + await zip.generateAsync({ type: 'blob' }).then(async function(imsBlob) { + await expect(extractIMSMetadata(imsBlob)).resolves.toEqual({ + title: 'Test File', + folders: [ + { + files: [ + { + identifierref: 'file1Ref', + resourceHref: 'file1.html', + title: 'Test File1', + }, + { + identifierref: 'file2Ref', + resourceHref: 'file2.html', + title: 'Test File2', + folders: [ + { + files: [ + { + identifierref: 'file1Ref', + resourceHref: 'file1.html', + title: 'Test File1', + }, + { + identifierref: 'file2Ref', + resourceHref: 'file2.html', + title: 'Test File2', + }, + ], + title: 'Folder 1', + }, + { + files: [ + { + identifierref: 'file3Ref', + resourceHref: 'file3.html', + title: 'Test File3', + }, + { + identifierref: 'file4Ref', + resourceHref: 'file4.html', + title: 'Test File4', + }, + ], + title: 'Folder 2', + }, + ], + }, + ], + title: 'Folder 1', + }, + { + files: [ + { + identifierref: 'file3Ref', + resourceHref: 'file3.html', + title: 'Test File3', + }, + { + identifierref: 'file4Ref', + resourceHref: 'file4.html', + title: 'Test File4', + }, + ], + title: 'Folder 2', + }, + ], + description: 'Example of test file', + language: 'en', + }); + }); + }); + it('extractIMSMetadata should extract metadata from multiple manifest file', async () => { + const manifestContent = ` + + + + Folder 1 + + Test File1 + + + Test File2 + + + + + + + + + + `; + + const subManifestContent = ` + + + + Folder 1 + + Test File3 + + + Test File4 + + + + + + + + + + `; + const zip = new JSZip(); + zip.file('imsmanifest.xml', manifestContent); + zip.file('file/imsmanifest.xml', subManifestContent); + + await zip.generateAsync({ type: 'blob' }).then(async function(imsBlob) { + await expect(extractIMSMetadata(imsBlob)).resolves.toEqual({ + folders: [ + { + files: [ + { + identifierref: 'file1Ref', + resourceHref: 'file/file1.html', + title: 'Test File1', + }, + { + identifierref: 'file2Ref', + resourceHref: 'file/file2.html', + title: 'Test File2', + }, + { + identifierref: 'file3Ref', + resourceHref: 'file3.html', + title: 'Test File3', + }, + { + identifierref: 'file4Ref', + resourceHref: 'file4.html', + title: 'Test File4', + }, + ], + title: 'Folder 1', + }, + ], + }); + }); + }); + it('extractIMSMetadata should extract metadata from imsmanifest and imsmetadata files', async () => { + const manifestContent = ``; + + const metadataContent = ` + + + + + Test File + + + en + + + Example of test file + + + + `; + const zip = new JSZip(); + zip.file('imsmanifest.xml', manifestContent); + zip.file('imsmetadata.xml', metadataContent); + + await zip.generateAsync({ type: 'blob' }).then(async function(imsBlob) { + await expect(extractIMSMetadata(imsBlob)).resolves.toEqual({ + title: 'Test File', + description: 'Example of test file', + language: 'en', + }); + }); + }); + it('extractIMSMetadata should not extract und language', async () => { + const manifestContent = ` + + + + + + \t\t\t\n\n\n\nTest File\n + + und + + + + `; + const zip = new JSZip(); + zip.file('imsmanifest.xml', manifestContent); + + await zip.generateAsync({ type: 'blob' }).then(async function(imsBlob) { + await expect(extractIMSMetadata(imsBlob)).resolves.toEqual({ + title: 'Test File', + }); + }); + }); + }); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/vuex/file/utils.js b/contentcuration/contentcuration/frontend/shared/vuex/file/utils.js index 12e59b943c..8f1f3da6f7 100644 --- a/contentcuration/contentcuration/frontend/shared/vuex/file/utils.js +++ b/contentcuration/contentcuration/frontend/shared/vuex/file/utils.js @@ -10,10 +10,17 @@ const MEDIA_PRESETS = [ FormatPresetsNames.AUDIO, FormatPresetsNames.HIGH_RES_VIDEO, FormatPresetsNames.LOW_RES_VIDEO, - FormatPresetsNames.H5P, + FormatPresetsNames.QTI, + FormatPresetsNames.HTML5_DEPENDENCY, + FormatPresetsNames.HTML5_ZIP, ]; const VIDEO_PRESETS = [FormatPresetsNames.HIGH_RES_VIDEO, FormatPresetsNames.LOW_RES_VIDEO]; const H5P_PRESETS = [FormatPresetsNames.H5P]; +const IMS_PRESETS = [ + FormatPresetsNames.QTI, + FormatPresetsNames.HTML5_DEPENDENCY, + FormatPresetsNames.HTML5_ZIP, +]; export function getHash(file) { return new Promise((resolve, reject) => { @@ -44,6 +51,159 @@ export function getHash(file) { }); } +async function getFolderMetadata(data, xmlDoc, zip, procssedFiles) { + const folders = []; + if (data.length && data[0].children && data[0].children.length) { + await Promise.all( + Object.values(data[0].children).map(async orgNode => { + const org = { + title: '', + files: [], + }; + if (orgNode.nodeType === 1) { + const title = orgNode.getElementsByTagName('title'); + org.title = title[0].textContent.trim(); + const files = orgNode.getElementsByTagName('item'); + const immediateChildNodes = []; + const childNodes = Object.values(orgNode.children); + Object.values(files).forEach(file => { + if (childNodes.includes(file)) { + immediateChildNodes.push(file); + } + }); + await Promise.all( + immediateChildNodes.map(async (fileNode, k) => { + const file = {}; + file.title = title[1 + k].textContent.trim(); + file.identifierref = fileNode.getAttribute('identifierref'); + file.resourceHref = xmlDoc + .querySelectorAll(`[identifier=${file.identifierref}]`)[0] + .getAttribute('href'); + if (fileNode.getElementsByTagName('organizations').length) { + getFolderMetadata( + fileNode.getElementsByTagName('organizations'), + xmlDoc, + zip, + procssedFiles + ).then(data => { + file.folders = data; + }); + } + const metadataNodes = orgNode.getElementsByTagName('metadata'); + if (metadataNodes && metadataNodes.length != 0) { + Object.values(metadataNodes).forEach(nodeValue => { + file[`${nodeValue[0].nodeName}`] = nodeValue[0].textContent.replace( + / {2}|\r\n|\n|\r/gm, + '' + ); + }); + } + org.files.push(file); + const manifestPath = + file.resourceHref.slice(0, file.resourceHref.lastIndexOf('/') + 1) + + 'imsmanifest.xml'; + const subManifestContent = zip.files[manifestPath]; + if (subManifestContent && !procssedFiles.includes(manifestPath)) { + procssedFiles.push(manifestPath); + const subManifestFile = await Promise.resolve(subManifestContent.async('text')); + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(subManifestFile, 'application/xml'); + const subManifestData = await getFolderMetadata( + xmlDoc.getElementsByTagName('organizations'), + xmlDoc, + zip, + procssedFiles + ); + if (subManifestData.title) { + org.title = subManifestData[0].title; + } + subManifestData[0].files.map(file => { + org.files.push(file); + }); + } + }) + ); + } + folders.push(org); + return org; + }) + ); + return folders; + } +} + +async function getManifestMetadata(manifestFile, zip, procssedFiles) { + const metadata = {}; + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(manifestFile, 'application/xml'); + const data = xmlDoc.getElementsByTagName('organizations'); + return await getFolderMetadata(data, xmlDoc, zip, procssedFiles).then(async data => { + if (data) { + metadata.folders = data; + } + const metadataFile = zip.file('imsmetadata.xml'); + if (metadataFile) { + procssedFiles.push('imsmetadata.xml'); + const content = await Promise.resolve(metadataFile.async('text')); + const xmlDoc = parser.parseFromString(content, 'application/xml'); + if (xmlDoc.getElementsByTagName('lomes:title').length) { + metadata.title = xmlDoc + .getElementsByTagName('lomes:title')[0] + .children[0].textContent.trim(); + } + if ( + xmlDoc.getElementsByTagName('lomes:idiom').length && + LanguagesMap.has(xmlDoc.getElementsByTagName('lomes:idiom')[0].textContent.trim()) && + xmlDoc.getElementsByTagName('lomes:idiom')[0].textContent.trim() !== 'und' + ) { + metadata.language = xmlDoc + .getElementsByTagName('lomes:idiom')[0] + .children[0].textContent.trim(); + } + if (xmlDoc.getElementsByTagName('lomes:description').length) { + metadata.description = xmlDoc + .getElementsByTagName('lomes:description')[0] + .children[0].textContent.trim(); + } + } else { + if (xmlDoc.getElementsByTagName('imsmd:title').length) { + metadata.title = xmlDoc.getElementsByTagName('imsmd:title')[0].textContent.trim(); + } + if ( + xmlDoc.getElementsByTagName('imsmd:language').length && + LanguagesMap.has(xmlDoc.getElementsByTagName('imsmd:language')[0].textContent.trim()) && + xmlDoc.getElementsByTagName('imsmd:language')[0].textContent.trim() !== 'und' + ) { + metadata.language = xmlDoc.getElementsByTagName('imsmd:language')[0].textContent.trim(); + } + if (xmlDoc.getElementsByTagName('imsmd:description').length) { + metadata.description = xmlDoc + .getElementsByTagName('imsmd:description')[0] + .textContent.trim(); + } + } + return metadata; + }); +} +export async function extractIMSMetadata(fileInput) { + const zip = new JSZip(); + const procssedFiles = []; + return zip + .loadAsync(fileInput) + .then(function(zip) { + const manifestFile = zip.file('imsmanifest.xml'); + if (!manifestFile) { + throw new Error('imsmanifest.xml not found in the zip file.'); + } else { + procssedFiles.push('imsmanifest.xml'); + return manifestFile.async('text'); + } + }) + .then(async manifestFile => { + return await getManifestMetadata(manifestFile, zip, procssedFiles); + }); +} + const extensionPresetMap = FormatPresetsList.reduce((map, value) => { if (value.display) { value.allowed_formats.forEach(format => { @@ -147,7 +307,7 @@ export function extractMetadata(file, preset = null) { } const isH5P = H5P_PRESETS.includes(metadata.preset); - + const isIMSCP = IMS_PRESETS.includes(metadata.preset); // Extract additional media metadata const isVideo = VIDEO_PRESETS.includes(metadata.preset); @@ -157,6 +317,11 @@ export function extractMetadata(file, preset = null) { Object.assign(metadata, data); }); resolve(metadata); + } else if (isIMSCP) { + extractIMSMetadata(file).then(data => { + Object.assign(metadata, data); + }); + resolve(metadata); } else { const mediaElement = document.createElement(isVideo ? 'video' : 'audio'); // Add a listener to read the metadata once it has loaded.