@missing-elements/h5p-embed 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -163,7 +163,29 @@ complete such an activity on viewing it instead. Replacing the zip with one writ
163
163
  belongs to what it was saved in.
164
164
 
165
165
  Checked in Moodle 4.5: the score reaches the gradebook, and a learner who leaves halfway continues
166
- there on the next launch. Other LMSs have not been tried yet.
166
+ there on the next launch.
167
+
168
+ Checked in Frappe LMS too, on its `develop` branch, uploaded as a SCORM chapter. A learner who
169
+ leaves halfway continues there, but Frappe records less than Moodle. It marks the lesson done only
170
+ when the content reports a pass, so a quiz needs a pass mark; a failed attempt, or content that
171
+ reports only `completed`, leaves the lesson open. It keeps no score. Frappe sends a file whole
172
+ rather than in ranges, so the package is downloaded in full when the lesson opens, and a large video
173
+ in it starts once the download has reached it.
174
+
175
+ Checked in SCORM Cloud too: the score, whether it passed, the time and the learner's place are
176
+ recorded, and an export without its libraries plays from the parts in the zip. A zip written by
177
+ embed 1.3.0 or earlier cannot be imported there ("IO Error processing zip"), nor, likely, into
178
+ other LMSs that read uploads with Java; write it again with a later version.
179
+
180
+ Checked in ILIAS 11 too, uploaded as a SCORM learning module. The score, whether it passed, the
181
+ time and the learner's place are recorded, but only once Learning Progress is on, which a fresh
182
+ ILIAS has off: without it ILIAS's SCORM player keeps the learner's place and drops every score and
183
+ status the content sends. Turn it on in Administration, then in the module's Learning Progress
184
+ settings choose "Collection of SCORM Items" and tick the item. ILIAS sent the larger packages whole
185
+ rather than in ranges, as Frappe does. With a zip written by embed 1.3.0 or earlier, ILIAS records
186
+ no time for a session the learner ends by closing the tab; write it again with a later version.
187
+
188
+ Other LMSs have not been tried yet.
167
189
 
168
190
  ## As a library
169
191
 
package/lib/crc32.mjs ADDED
@@ -0,0 +1,36 @@
1
+ import zlib from 'node:zlib'
2
+
3
+ /** @type {Uint32Array | undefined} */
4
+ let table
5
+
6
+ /**
7
+ * @param {Uint8Array} bytes
8
+ * @param {number} previous
9
+ */
10
+ function tableCrc32(bytes, previous) {
11
+ if (!table) {
12
+ table = new Uint32Array(256)
13
+ for (let n = 0; n < 256; n++) {
14
+ let c = n
15
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
16
+ table[n] = c >>> 0
17
+ }
18
+ }
19
+ let crc = ~previous >>> 0
20
+ for (let i = 0; i < bytes.length; i++) crc = table[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8)
21
+ return ~crc >>> 0
22
+ }
23
+
24
+ /**
25
+ * CRC-32 of `bytes`, continuing from `previous`. Native from Node 22.2 on; a table before that.
26
+ * A namespace import rather than a named one, because a named import of a builtin's missing
27
+ * export fails at link time and would take the whole script down on an older Node.
28
+ *
29
+ * A copy of the normalizer's `lib/crc32.mjs`; keep the two in step.
30
+ *
31
+ * @type {(bytes: Uint8Array, previous?: number) => number}
32
+ */
33
+ export const crc32 =
34
+ typeof zlib.crc32 === 'function'
35
+ ? (bytes, previous = 0) => zlib.crc32(bytes, previous)
36
+ : (bytes, previous = 0) => tableCrc32(bytes, previous)
package/lib/scorm.mjs CHANGED
@@ -6,6 +6,7 @@ import { Writable } from 'node:stream'
6
6
  import { fileURLToPath } from 'node:url'
7
7
  import { BlobReader, TextReader, TextWriter, ZipReader, ZipWriter, configure } from '@zip.js/zip.js'
8
8
  import { EmbedError, PLAYER_FILES, installed } from './build.mjs'
9
+ import { crc32 } from './crc32.mjs'
9
10
 
10
11
  /**
11
12
  * Writes a SCORM 1.2 package that plays one `.h5p` in an LMS and reports its result there: the
@@ -382,6 +383,16 @@ function sha256(file) {
382
383
  })
383
384
  }
384
385
 
386
+ /**
387
+ * A file's CRC-32, read as a stream.
388
+ * @param {string} file
389
+ */
390
+ async function fileCrc32(file) {
391
+ let crc = 0
392
+ for await (const chunk of createReadStream(file)) crc = crc32(chunk, crc)
393
+ return crc
394
+ }
395
+
385
396
  /**
386
397
  * Every file under a folder, as zip paths relative to it, sorted.
387
398
  * @param {string} dir
@@ -533,9 +544,20 @@ export async function buildScorm({ h5p, out, force = false }) {
533
544
  const zip = new ZipWriter(Writable.toWeb(createWriteStream(partial)))
534
545
  await zip.add('imsmanifest.xml', new TextReader(xml))
535
546
  for (const [name, content] of files) {
536
- // The package, the pack's parts and the fonts are compressed already.
537
- const level = name.endsWith('.h5p') || name.endsWith('.woff2') ? 0 : 6
538
- await zip.add(name, 'text' in content ? new TextReader(content.text) : new BlobReader(await openAsBlob(content.file)), { level })
547
+ if ('text' in content) {
548
+ await zip.add(name, new TextReader(content.text), { level: 6 })
549
+ continue
550
+ }
551
+ const blob = await openAsBlob(content.file)
552
+ // The package, the pack's parts and the fonts are compressed already, so they are stored.
553
+ // Written into a stream, an entry gets a data descriptor, and a Java ZipInputStream refuses
554
+ // a stored entry that has one: SCORM Cloud refused exactly those (#92). Passed through with
555
+ // its size and CRC, it gets a complete local header instead, and is still streamed, not held.
556
+ if (name.endsWith('.h5p') || name.endsWith('.woff2')) {
557
+ await zip.add(name, new BlobReader(blob), { passThrough: true, compressionMethod: 0, uncompressedSize: blob.size, crc32: await fileCrc32(content.file) })
558
+ } else {
559
+ await zip.add(name, new BlobReader(blob), { level: 6 })
560
+ }
539
561
  }
540
562
  await zip.close()
541
563
  await rename(partial, target)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@missing-elements/h5p-embed",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Self-hosted H5P embeds: writes the H5P embed page as a static site for a player domain of your own, then any site frames it with an iframe and one script line; or wraps one .h5p as a SCORM 1.2 package an LMS plays and grades. No H5P server, no third-party service; optional allowlists for package hosts and embedding sites.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/scorm/scorm.js CHANGED
@@ -151,13 +151,36 @@ player.addEventListener('finished', (event) => {
151
151
  commit()
152
152
  })
153
153
 
154
- // The session ends with the page. Unfinished, it is suspended, so the next launch resumes it.
154
+ /*
155
+ * The session ends with the page. Unfinished, it is suspended, so the next launch resumes it.
156
+ *
157
+ * Its time and how it ended are committed on `beforeunload` too, while the page is still there,
158
+ * and the session is finished on `pagehide`, once the page is going for good. ILIAS sends what a
159
+ * closing page writes with a beacon only during `beforeunload`, `unload` or a click; on `pagehide`
160
+ * it tries a synchronous request, which Chrome refuses while a page goes, so the finish alone lost
161
+ * the session's time there (#94). The commit is all `beforeunload` does: the content saves its
162
+ * state for the last time on its own frame's `beforeunload`, which comes after this page's, and a
163
+ * learner asked whether to leave may stay.
164
+ */
155
165
  let ended = false
166
+
167
+ function writeEnd() {
168
+ set('cmi.core.session_time', timespan(Date.now() - started))
169
+ set('cmi.core.exit', finished ? '' : 'suspend')
170
+ }
171
+
172
+ if (initialized) {
173
+ addEventListener('beforeunload', () => {
174
+ if (ended) return
175
+ writeEnd()
176
+ commit()
177
+ })
178
+ }
179
+
156
180
  addEventListener('pagehide', () => {
157
181
  if (!initialized || ended || !api) return
158
182
  ended = true
159
- set('cmi.core.session_time', timespan(Date.now() - started))
160
- set('cmi.core.exit', finished ? '' : 'suspend')
183
+ writeEnd()
161
184
  api.LMSFinish('')
162
185
  })
163
186
 
@@ -0,0 +1,10 @@
1
+ /**
2
+ * CRC-32 of `bytes`, continuing from `previous`. Native from Node 22.2 on; a table before that.
3
+ * A namespace import rather than a named one, because a named import of a builtin's missing
4
+ * export fails at link time and would take the whole script down on an older Node.
5
+ *
6
+ * A copy of the normalizer's `lib/crc32.mjs`; keep the two in step.
7
+ *
8
+ * @type {(bytes: Uint8Array, previous?: number) => number}
9
+ */
10
+ export declare const crc32: (bytes: Uint8Array, previous?: number) => number;