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.