diff --git a/src/media.ts b/src/media.ts index 832ef4cf..6ef2e76c 100644 --- a/src/media.ts +++ b/src/media.ts @@ -115,6 +115,7 @@ const TARGET_SPECS: TargetSpec[] = [ { selector: 'embed_activity source', attr: 'text' }, { selector: 'linked_activity source', attr: 'text' }, { selector: 'asset, interface, dataset', attr: 'text' }, + { selector: 'applet param wb\\:path', attr: 'href' }, // in case process HTML, not used in OLI xml { selector: 'script', attr: 'src' }, { selector: 'img', attr: 'src' }, @@ -178,7 +179,8 @@ export function transformToFlatDirectory( $(e).parent()[0].name === 'linked_activity')); const isWebBundleElement = (e: cheerio.Element) => - ['link', 'iframe'].includes($(e)[0].name) || isSuperactivityAsset(e); + ['link', 'iframe', 'wb:path'].includes($(e)[0].name) || + isSuperactivityAsset(e); const targets = collectTargets($, 'relative'); diff --git a/src/resources/common.ts b/src/resources/common.ts index a4a48db0..a7aa1593 100644 --- a/src/resources/common.ts +++ b/src/resources/common.ts @@ -112,6 +112,7 @@ export function failIfHasValue( export function standardContentManipulations($: any) { failIfPresent($, ['ipa', 'bdo']); + handleJmolApplets($); handleCommandButtons($); DOM.unwrapInlinedMedia($, 'video'); @@ -297,9 +298,9 @@ export function standardContentManipulations($: any) { DOM.rename($, 'mtemp>material', 'p'); DOM.eliminateLevel($, 'mtemp'); - // Strip composite-activity if only one child + DOM.rename($, 'composite_activity > instructions', 'p'); + // Strip composite-activity entirely if only one child DOM.eliminateLevel($, 'composite_activity:has(> :only-child)'); - DOM.rename($, 'composite_activity', 'group'); // Strip alternatives within feedback/explanation, won't work. @@ -343,13 +344,8 @@ export function standardContentManipulations($: any) { DOM.renameAttribute($, 'video source', 'src', 'url'); DOM.renameAttribute($, 'video', 'type', 'contenttype'); DOM.renameAttribute($, 'audio', 'type', 'audioType'); - // video subelement with .vtt subtitle file => torus captions child - DOM.rename($, 'video track[kind="subtitles"]', 'captions'); - DOM.renameAttribute($, 'video captions', 'srclang', 'language_code'); DOM.rename($, 'extra', 'popup'); - // simplifies handling within popup: - DOM.eliminateLevel($, 'meaning > material'); handleTheorems($); handleFormulaMathML($); @@ -407,6 +403,81 @@ function handleCommandButtons($: any) { $('command_button').wrap('

'); } +function handleJmolApplets($: any) { + const jmolWrapper = 'edu.cmu.oli.messaging.applet.jmol.JmolAppletWrapper'; + + const normalizeScriptValue = (value: string) => + value + .replace(/[\r\n]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + $('applet').each((i: any, elem: any) => { + const codeAttr = ($(elem).attr('code') || '').trim(); + const isJmol = codeAttr.includes(jmolWrapper); + + if (!isJmol) { + return; + } + + const idref = $(elem).attr('id') || ''; + const widthAttr = $(elem).attr('width'); + const heightAttr = $(elem).attr('height'); + + const params: Record = {}; + + $(elem) + .children('param') + .each((_j: any, p: any) => { + const name = ($(p).attr('name') || '').trim(); + if (!name) return; + + let raw = ''; + if (name === 'load') { + // In legacy use, molecule files were always loaded from webcontent, not public databases. + // These file loads were same-origin as jsmolframe page in legacy, but wind up cross-origin on + // torus (because frame comes from torus superactivity space but data files come from S3). For + // cross-origin loads, jsmol normally calls into a back-end jsmol.php on a specified server to + // avoid CORS problems, but Torus doesn't run this and we don't want to rely on an external server. + // JSMOL supports including a magic ?ALLOWSORIGIN? marker parameter in the URL to tell it + // host allows our origin via CORS, so we always add that to ensure it will do a direct load. + const wbPath = $(p).find('wb\\:path'); + const href = wbPath.attr('href'); + raw = href !== undefined ? href : $(p).text(); + raw = raw + '?ALLOWSORIGIN?'; + } else if (name === 'script') { + raw = normalizeScriptValue($(p).text()); + } else { + raw = $(p).text().trim(); + } + + params[name] = raw; + }); + + const encodedParams = encodeURIComponent(JSON.stringify(params)); + const src = + '/superactivity/jsmol/jmolframe.html' + + `?idref=${encodeURIComponent(idref)}&params=${encodedParams}`; + + const iframe = $(''); + if (idref) { + iframe.attr('id', idref); + } + iframe.attr('src', src); + iframe.attr('scrolling', 'no'); + iframe.attr('frameborder', '0'); + + if (widthAttr !== undefined) { + iframe.attr('width', String(Number(widthAttr) + 5)); + } + if (heightAttr !== undefined) { + iframe.attr('height', String(Number(heightAttr) + 5)); + } + + $(elem).replaceWith(iframe); + }); +} + // We don't want empty caption tags ie to be expanded to an empty caption with

in it for iframes (maybe others?) // This will remove those before they get expanded function stripEmptyCaptions($: any, selector: string) { diff --git a/src/utils/xml.ts b/src/utils/xml.ts index 51725282..ab973fbe 100644 --- a/src/utils/xml.ts +++ b/src/utils/xml.ts @@ -16,11 +16,17 @@ const xmlParser = require('./parser'); export type TagVisitor = (tag: string, attributes: unknown) => void; export type ClosingTagVisitor = (tag: string) => void; +// one course left commented out old DOCTYPE when upgrading, must ignore +function stripCommentedDoctype(content: string): string { + return content.replace(//gi, ''); +} + function getPastDocType(content: string): string { - if (content.indexOf('DOCTYPE') !== -1) { - return content.substr(content.indexOf('>', content.indexOf('DOCTYPE')) + 1); + const cleaned = stripCommentedDoctype(content); + if (cleaned.indexOf('DOCTYPE') !== -1) { + return cleaned.substr(cleaned.indexOf('>', cleaned.indexOf('DOCTYPE')) + 1); } - return content; + return cleaned; } export function isBlockElement(name: string) { @@ -749,7 +755,8 @@ export function toJSON( export function rootTag(file: string): Promise { return new Promise((resolve, _reject) => { const content: string = fs.readFileSync(file, 'utf-8'); - const dtd = content.substr(content.indexOf('') + 1).replace(/\s+/g, ' ')); }); diff --git a/test/content/webcontent/jmol/jmol/pdbs/methane_mir.pdb b/test/content/webcontent/jmol/jmol/pdbs/methane_mir.pdb new file mode 100644 index 00000000..0c5c581c --- /dev/null +++ b/test/content/webcontent/jmol/jmol/pdbs/methane_mir.pdb @@ -0,0 +1 @@ +REMARK dummy test fixture for JMOL webcontent bundle resolution diff --git a/test/content/x-oli-workbook_page/jmol-applet.xml b/test/content/x-oli-workbook_page/jmol-applet.xml new file mode 100644 index 00000000..abf90250 --- /dev/null +++ b/test/content/x-oli-workbook_page/jmol-applet.xml @@ -0,0 +1,23 @@ + + + + + JMOL Test + + + + true + + + + set background white; + color cpk; wireframe 0.2; + + + diff --git a/test/jmol-applet-test.ts b/test/jmol-applet-test.ts new file mode 100644 index 00000000..c91c4ce5 --- /dev/null +++ b/test/jmol-applet-test.ts @@ -0,0 +1,59 @@ +import * as path from 'path'; +import { MediaSummary } from 'src/media'; +import { ProjectSummary } from 'src/project'; +import { convert } from 'src/convert'; + +const mediaSummary: MediaSummary = { + mediaItems: {}, + missing: [], + urlPrefix: 'https://example.com/media', + downloadRemote: false, + flattenedNames: {}, + webContentBundle: { + name: 'test', + url: 'https://example.com/media/bundles/test', + items: [], + totalSize: 0, + }, +}; + +const projectSummary = new ProjectSummary( + path.resolve('./test'), + '', + '', + mediaSummary +); + +describe('jmol applet', () => { + let results: any = {}; + + beforeAll(async () => { + return convert( + projectSummary, + './test/content/x-oli-workbook_page/jmol-applet.xml', + true + ).then((r) => { + results = r; + }); + }); + + test('should convert jmol applet to iframe with encoded params', () => { + const content = results[0].content.model[0].children; + const [iframe] = content; + + const params = { + progressbar: 'true', + load: 'https://example.com/media/bundles/test/webcontent/jmol/jmol/pdbs/methane_mir.pdb?ALLOWSORIGIN?', + script: 'set background white; color cpk; wireframe 0.2;', + }; + + const expectedSrc = + '/superactivity/jsmol/jmolframe.html' + + `?idref=methane2¶ms=${encodeURIComponent(JSON.stringify(params))}`; + + expect(iframe.type).toBe('iframe'); + expect(iframe.src).toBe(expectedSrc); + expect(iframe.width).toBe('130'); + expect(iframe.height).toBe('130'); + }); +});