@missing-elements/h5p-embed 1.3.0 → 1.3.2

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
@@ -2,7 +2,8 @@
2
2
 
3
3
  Writes the H5P embed page as a static site for a player domain of your own. Any site then embeds
4
4
  a package with an iframe and one script line. No H5P server, no backend, no third-party service.
5
- With `--scorm`, it writes one package as a SCORM zip instead, for an LMS to play and grade (see
5
+ With `--scorm`, it writes one package as a SCORM zip instead, for an LMS such as Moodle, ILIAS,
6
+ OpenOLAT or Chamilo to play and grade (see
6
7
  [A SCORM package, for an LMS](#a-scorm-package-for-an-lms)).
7
8
 
8
9
  Needs Node 22 or later. Write `@latest`: without it, npm on an older Node quietly installs the
@@ -133,8 +134,8 @@ npx @missing-elements/h5p-embed@latest --scorm course.h5p # writes ./course.
133
134
  ```
134
135
 
135
136
  writes one package as a SCORM 1.2 zip: the package, the player and the H5P runtime, and a launch
136
- page that reports to the LMS. Add it to a course as a SCORM activity, in Moodle or another LMS that
137
- takes SCORM 1.2, and the LMS records:
137
+ page that reports to the LMS. Add it to a course as a SCORM activity, in one of the
138
+ [supported LMSs](#supported-lmss) or another that takes SCORM 1.2, and the LMS records:
138
139
 
139
140
  - the score out of 100, and `passed` or `failed` when the content says whether it was passed,
140
141
  `completed` when it does not. The content's last result counts, as on an H5P site;
@@ -143,9 +144,6 @@ takes SCORM 1.2, and the LMS records:
143
144
  launch continues from it. SCORM 1.2 keeps 4,096 characters, enough for a quiz; a larger state is
144
145
  not saved.
145
146
 
146
- Moodle records the score as the grade, out of the activity's maximum grade, so leave that at 100,
147
- its default.
148
-
149
147
  An export from h5p.com or h5p.org, which leaves the libraries out, works as well: the zip carries
150
148
  the ones it needs from the library pack, and only those. For a quiz with formulas, that is 11
151
149
  libraries, 4 MB, most of it the formula renderer. A package that carries its libraries, as exports
@@ -154,16 +152,58 @@ a library neither it nor the pack has, or a newer version than the pack's, is re
154
152
  missing named.
155
153
 
156
154
  The package plays trusted: its own code runs on the LMS's origin, as every SCORM package's does.
157
- The LMS has to be served over https, since the player needs its Service Worker, and Moodle has to
158
- serve files by path, which it does by default (`slasharguments`).
155
+ The LMS has to be served over https, since the player needs its Service Worker.
159
156
 
160
157
  Content that reports no result of its own, an Accordion or a Virtual Tour, stays `incomplete`:
161
158
  complete such an activity on viewing it instead. Replacing the zip with one written from another
162
159
  `.h5p`, or with other libraries from the pack, starts learners over, since their saved state
163
160
  belongs to what it was saved in.
164
161
 
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.
162
+ ### Supported LMSs
163
+
164
+ Checked at these versions, as a learner in Chrome. Other LMSs have not been tried yet.
165
+
166
+ | LMS | Add the zip as | Change from the defaults | The package is read |
167
+ |---|---|---|---|
168
+ | Moodle 4.5 | a SCORM package activity | nothing; keep the maximum grade at 100 | in ranges |
169
+ | ILIAS 11 | a SCORM learning module | turn Learning Progress on | whole |
170
+ | OpenOLAT 21.1 | a "SCORM 1.2" course element | the pass mark; in a learning path, the completion criterion | in ranges |
171
+ | Chamilo 3.0 | an imported learning path | publish the learning path | whole |
172
+ | SCORM Cloud | a course | nothing | in ranges |
173
+ | Frappe LMS, `develop` | a SCORM chapter | nothing; the content needs a pass mark | whole |
174
+
175
+ All of them record the score, whether it passed, the time and the learner's place, except Frappe
176
+ LMS, which records only a pass and the learner's place. Where the package is read whole, it is
177
+ downloaded in full when it opens, and a large video in it starts once the download has reached it;
178
+ a quiz does not notice.
179
+
180
+ **Moodle.** The score goes to the gradebook as the grade, out of the activity's maximum grade, so
181
+ keep that at 100, its default. Moodle has to serve files by path, which it does by default
182
+ (`slasharguments`).
183
+
184
+ **ILIAS.** Learning Progress is off in a fresh ILIAS, and without it ILIAS's SCORM player keeps
185
+ the learner's place and drops every score and status the content sends. Turn it on in
186
+ Administration, then in the module's Learning Progress settings choose "Collection of SCORM Items"
187
+ and tick the item. ILIAS sent the larger packages whole; only one of a few kilobytes came in ranges.
188
+
189
+ **OpenOLAT.** Its grading takes the score with a pass mark of 0, so every result counts as passed, a
190
+ failed quiz too: set "Score needed to pass" to the quiz's own pass mark, or choose "Transfer passed
191
+ value", which takes pass or fail from the content and no score. In a learning-path course the
192
+ element counts as done only once the learner confirms it: choose "Passed" as its completion
193
+ criterion.
194
+
195
+ **Chamilo.** Chamilo imports a learning path unpublished, so publish it before learners can see it.
196
+ Chamilo 1.11 has not been tried.
197
+
198
+ **Frappe LMS.** Frappe marks the lesson done only when the content reports a pass, so a quiz needs a
199
+ pass mark; a failed attempt, or content that reports only `completed`, leaves the lesson open. It
200
+ keeps no score.
201
+
202
+ A zip written by embed 1.3.0 or earlier is worth writing again with the current version. SCORM
203
+ Cloud cannot import it ("IO Error processing zip"), nor, likely, can some other LMSs written in
204
+ Java, though OpenOLAT can; ILIAS keeps no time for a session played from it that the learner ends
205
+ by closing the tab, and Chamilo does not record that such a session was left unfinished, though it
206
+ resumes it.
167
207
 
168
208
  ## As a library
169
209
 
package/lib/scorm.mjs CHANGED
@@ -3,8 +3,10 @@ import { createReadStream, createWriteStream, openAsBlob } from 'node:fs'
3
3
  import { readdir, readFile, rename, rm, stat } from 'node:fs/promises'
4
4
  import { basename, dirname, extname, join, resolve, sep } from 'node:path'
5
5
  import { Writable } from 'node:stream'
6
- import { fileURLToPath } from 'node:url'
6
+ import { fileURLToPath, pathToFileURL } from 'node:url'
7
7
  import { BlobReader, TextReader, TextWriter, ZipReader, ZipWriter, configure } from '@zip.js/zip.js'
8
+ import { crc32 } from '@missing-elements/h5p-normalize/crc32'
9
+ import { dependenciesIn, indexLibraryFolders, librariesNamedIn, ownLibraries, planPack, reachableFolder, walkDependencies } from '@missing-elements/h5p-offline-player/plan'
8
10
  import { EmbedError, PLAYER_FILES, installed } from './build.mjs'
9
11
 
10
12
  /**
@@ -33,266 +35,22 @@ const PACKAGE = 'package.h5p'
33
35
  const LIBRARIES = 'libraries'
34
36
 
35
37
  /**
36
- * A dependency as `h5p.json` and every `library.json` list them, or nothing for an entry that is
37
- * not one.
38
- * @param {unknown} json
39
- * @returns {{ machineName: string, major: number, minor: number }[]}
40
- */
41
- function dependenciesIn(json) {
42
- const list = /** @type {{ preloadedDependencies?: unknown }} */ (json ?? {}).preloadedDependencies
43
- if (!Array.isArray(list)) return []
44
- return list.flatMap((dependency) => {
45
- const major = Number(dependency?.majorVersion)
46
- const minor = Number(dependency?.minorVersion)
47
- return typeof dependency?.machineName === 'string' && Number.isInteger(major) && Number.isInteger(minor)
48
- ? [{ machineName: dependency.machineName, major, minor }]
49
- : []
50
- })
51
- }
52
-
53
- /**
54
- * The folder that serves a dependency, by the player's own rule: its versioned or bare name when
55
- * that folder has a `library.json`, else the lowest versioned folder of the same major with a
56
- * minor at least as high (`reachableFolder` in the player's `package-reader.ts`).
57
- * @internal
58
- * @param {{ machineName: string, major: number, minor: number }} dependency
59
- * @param {ReadonlySet<string>} names the archive's entry names
60
- * @returns {string | undefined}
61
- */
62
- export function servingFolder({ machineName, major, minor }, names) {
63
- const own = [`${machineName}-${major}.${minor}`, machineName].find((folder) => names.has(`${folder}/library.json`))
64
- if (own) return own
65
- let best
66
- for (const name of names) {
67
- const match = /^(.+)-(\d+)\.(\d+)\/library\.json$/.exec(name)
68
- if (!match || match[1] !== machineName || Number(match[2]) !== major || Number(match[3]) < minor) continue
69
- if (!best || Number(match[3]) < best.minor) best = { folder: `${match[1]}-${match[2]}.${match[3]}`, minor: Number(match[3]) }
70
- }
71
- return best?.folder
72
- }
73
-
74
- /**
75
- * What the content asks for, and what those libraries need in turn, that no folder in `names`
76
- * serves: the player refuses such a package when it opens it, and the deep walk is the one it makes
77
- * before the runtime boots (`missingDependencies`). The sub-content the parameters name counts
78
- * too, which the player finds missing only when the runtime reaches it.
79
- * @param {{ machineName: string, major: number, minor: number }[]} roots
80
- * @param {ReadonlySet<string>} names
81
- * @param {(name: string) => Promise<unknown>} read a `library.json` among `names`, parsed
82
- * @returns {Promise<{ machineName: string, major: number, minor: number }[]>}
83
- */
84
- async function missingLibraries(roots, names, read) {
85
- /** @type {Map<string, { machineName: string, major: number, minor: number }>} */
86
- const missing = new Map()
87
- const seen = new Set()
88
- let level = roots
89
- while (level.length > 0) {
90
- const next = []
91
- for (const dependency of level) {
92
- const key = `${dependency.machineName} ${dependency.major}.${dependency.minor}`
93
- if (seen.has(key)) continue
94
- seen.add(key)
95
- const folder = servingFolder(dependency, names)
96
- if (!folder) missing.set(key, dependency)
97
- else next.push(...dependenciesIn(await read(`${folder}/library.json`)))
98
- }
99
- level = next
100
- }
101
- return [...missing].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([, dependency]) => dependency)
102
- }
103
-
104
- /*
105
- * The library pack's part of the work: which of its parts the package needs. These follow the
106
- * player's own functions, named on each, so that the element, planning from the zip's index,
107
- * asks for exactly the parts the zip carries; keep them in step.
108
- */
109
-
110
- /**
111
- * The sub-content libraries the parameters name, by each object's `library` string, as
112
- * `"H5P.MultiChoice 1.16"`: what a full export's `h5p.json` lists and a stripped one's does not.
113
- * The player's `librariesNamedIn`.
114
- * @param {unknown} params
115
- * @returns {{ machineName: string, major: number, minor: number }[]}
116
- */
117
- function librariesNamedIn(params) {
118
- /** @type {Map<string, { machineName: string, major: number, minor: number }>} */
119
- const found = new Map()
120
- /** @param {unknown} value @param {number} depth */
121
- const walk = (value, depth) => {
122
- if (depth > 64 || !value || typeof value !== 'object') return
123
- if (Array.isArray(value)) {
124
- for (const item of value) walk(item, depth + 1)
125
- return
126
- }
127
- const library = /** @type {{ library?: unknown }} */ (value).library
128
- if (typeof library === 'string') {
129
- const match = /^([A-Za-z0-9_.-]+) (\d+)\.(\d+)$/.exec(library)
130
- if (match && !found.has(match[1])) found.set(match[1], { machineName: match[1], major: Number(match[2]), minor: Number(match[3]) })
131
- }
132
- for (const child of Object.values(value)) walk(child, depth + 1)
133
- }
134
- walk(params, 0)
135
- return [...found.values()]
136
- }
137
-
138
- /**
139
- * An addon's patterns, under `addTo.content.types[].text.regex`, PHP's `/…/flags` made
140
- * JavaScript's; one JavaScript cannot read, or with a flag it has no counterpart for, is left out.
141
- * The player's `addonPatterns`.
142
- * @param {unknown} addTo
143
- * @returns {RegExp[]}
144
- */
145
- function addonPatterns(addTo) {
146
- const types = /** @type {{ content?: { types?: unknown } } | null | undefined} */ (addTo)?.content?.types
147
- if (!Array.isArray(types)) return []
148
- const patterns = []
149
- for (const type of types) {
150
- const source = type?.text?.regex
151
- if (typeof source !== 'string' || source.length < 2) continue
152
- const delimiter = source[0]
153
- if (/[\p{L}\p{N}\\\s([{<]/u.test(delimiter)) continue
154
- const end = source.lastIndexOf(delimiter)
155
- const flags = source.slice(end + 1)
156
- if (end < 1 || /[^imsu]/.test(flags)) continue
157
- try {
158
- patterns.push(new RegExp(source.slice(1, end), flags))
159
- } catch {
160
- // PCRE that JavaScript does not read.
161
- }
162
- }
163
- return patterns
164
- }
165
-
166
- /**
167
- * Whether a string anywhere in the parameters matches one of the patterns. The player's
168
- * `textMatches`.
169
- * @param {unknown} params
170
- * @param {RegExp[]} patterns
171
- */
172
- function textMatches(params, patterns) {
173
- /** @param {unknown} value @param {number} depth @returns {boolean} */
174
- const walk = (value, depth) => {
175
- if (typeof value === 'string') return patterns.some((pattern) => pattern.test(value))
176
- if (depth > 64 || !value || typeof value !== 'object') return false
177
- return Object.values(value).some((child) => walk(child, depth + 1))
178
- }
179
- return patterns.length > 0 && walk(params, 0)
180
- }
181
-
182
- /**
183
- * The library folders the package carries, each as its `library.json` says: the player's
184
- * `ownLibraries` once every `library.json` is at hand. One that does not parse, or a folder named
185
- * for another library, is left out.
186
- * @param {Iterable<string>} names
187
- * @param {(name: string) => Promise<unknown>} read
188
- */
189
- async function ownLibraries(names, read) {
190
- /** @param {unknown} part */
191
- const version = (part) => (typeof part === 'number' && Number.isInteger(part) && part >= 0 ? part : typeof part === 'string' && /^\d+$/.test(part) ? Number(part) : null)
192
- const libraries = []
193
- for (const name of names) {
194
- const slash = name.indexOf('/')
195
- if (slash < 0 || name.slice(slash + 1) !== 'library.json') continue
196
- const folder = name.slice(0, slash)
197
- const library = /** @type {{ machineName?: unknown, majorVersion?: unknown, minorVersion?: unknown } | null} */ (await read(name).catch(() => null))
198
- const major = version(library?.majorVersion)
199
- const minor = version(library?.minorVersion)
200
- const machineName = library?.machineName
201
- if (typeof machineName !== 'string' || major === null || minor === null) continue
202
- const versioned = /^(.+)-(\d+)\.(\d+)$/.exec(folder)
203
- if (versioned ? versioned[1] !== machineName || Number(versioned[2]) !== major : folder !== machineName) continue
204
- libraries.push({ folder, machineName, major, minor, dependencies: dependenciesIn(library) })
205
- }
206
- return libraries
207
- }
208
-
209
- /**
210
- * The library pack's index, its `parts/index.json`, as the plan reads it. `entry` is the library's
211
- * own record, which the zip's index repeats.
38
+ * The library pack's index, its `parts/index.json`, as the plan takes it: each library's record,
39
+ * which the zip's index repeats as `record`, with the part's file as `url`. A record the index got
40
+ * wrong is left out.
212
41
  * @param {unknown} index
42
+ * @param {string} packDir the folder holding the index and the parts
213
43
  */
214
- function packLibraries(index) {
44
+ function packLibraries(index, packDir) {
215
45
  const list = /** @type {{ libraries?: unknown } | null} */ (index)?.libraries
216
- return (Array.isArray(list) ? list : []).flatMap((entry) => {
217
- const { folder, machineName, majorVersion, minorVersion, sha256, addTo } = entry ?? {}
218
- return typeof folder === 'string' && typeof machineName === 'string' && Number.isInteger(majorVersion) && Number.isInteger(minorVersion) && typeof sha256 === 'string'
219
- ? [{ folder, machineName, major: Number(majorVersion), minor: Number(minorVersion), sha256, dependencies: dependenciesIn(entry), addTo, entry: /** @type {object} */ (entry) }]
220
- : []
46
+ return (Array.isArray(list) ? list : []).flatMap((record) => {
47
+ const { folder, machineName, majorVersion, minorVersion, patchVersion, size, sha256 } = record ?? {}
48
+ if (typeof folder !== 'string' || typeof machineName !== 'string' || ![majorVersion, minorVersion, patchVersion, size].every(Number.isInteger) || typeof sha256 !== 'string') return []
49
+ const library = /** @type {import('@missing-elements/h5p-offline-player/plan').PackLibrary} */ (record)
50
+ return [{ ...library, url: pathToFileURL(join(packDir, `${folder}.h5p`)).href, record: library }]
221
51
  })
222
52
  }
223
53
 
224
- /**
225
- * The parts of the library pack the package needs, and the folders of its own they replace: the
226
- * player's `planPack` (`sw/pack-plan.ts`) as the launch page has the element run it, filling gaps
227
- * with nothing attached before (`refresh-libraries="off"`).
228
- * - What the content asks for, and the package carries at that major and at no lower minor, plays
229
- * from the package. Anything else comes from the pack, which has one version of each major, and
230
- * so does whatever a part needs; an older minor the package carries gives way to the part.
231
- * - Once anything comes from the pack, so do the addons whose pattern matches a string in the
232
- * parameters: H5P.MathDisplay, for LaTeX.
233
- * @param {object} input
234
- * @param {Awaited<ReturnType<typeof ownLibraries>>} input.own
235
- * @param {{ machineName: string, major: number, minor: number }[]} input.roots what `h5p.json`
236
- * declares and the parameters name as sub-content
237
- * @param {unknown} input.params
238
- * @param {ReturnType<typeof packLibraries>} input.pack
239
- */
240
- function planParts({ own, roots, params, pack }) {
241
- const byLibrary = new Map(pack.map((library) => [`${library.machineName} ${library.major}`, library]))
242
- /** @type {Map<string, ReturnType<typeof packLibraries>[number]>} */
243
- const planned = new Map()
244
- /** @type {string[]} */
245
- const superseded = []
246
- let carried = own
247
- const queue = [...roots]
248
- const seen = new Set()
249
-
250
- /**
251
- * @param {{ machineName: string, major: number, minor: number }} version
252
- * @param {{ machineName: string, major: number, minor: number }} dependency
253
- */
254
- const satisfies = (version, dependency) =>
255
- version.machineName === dependency.machineName && version.major === dependency.major && version.minor >= dependency.minor
256
- /** @param {ReturnType<typeof packLibraries>[number]} library */
257
- const plan = (library) => {
258
- if (planned.has(library.folder)) return
259
- planned.set(library.folder, library)
260
- queue.push(...library.dependencies)
261
- }
262
- const follow = () => {
263
- for (let next = queue.shift(); next; next = queue.shift()) {
264
- const dependency = next
265
- const key = `${dependency.machineName} ${dependency.major}.${dependency.minor}`
266
- if (seen.has(key)) continue
267
- seen.add(key)
268
- const ownVersion = carried.find((library) => satisfies(library, dependency))
269
- if (ownVersion) {
270
- queue.push(...ownVersion.dependencies)
271
- continue
272
- }
273
- if ([...planned.values()].some((library) => satisfies(library, dependency))) continue
274
- const candidate = byLibrary.get(`${dependency.machineName} ${dependency.major}`)
275
- if (!candidate || candidate.minor < dependency.minor) continue
276
- const older = carried.find((library) => library.machineName === dependency.machineName && library.major === dependency.major)
277
- if (older) {
278
- carried = carried.filter((library) => library !== older)
279
- superseded.push(older.folder)
280
- }
281
- plan(candidate)
282
- }
283
- }
284
-
285
- follow()
286
- if (planned.size > 0) {
287
- for (const addon of pack) {
288
- if (addon.addTo === undefined || carried.some((library) => library.machineName === addon.machineName)) continue
289
- if (textMatches(params, addonPatterns(addon.addTo))) plan(addon)
290
- }
291
- follow()
292
- }
293
- return { parts: [...planned.values()].sort((a, b) => (a.folder < b.folder ? -1 : a.folder > b.folder ? 1 : 0)), superseded }
294
- }
295
-
296
54
  /** @param {string} text */
297
55
  const escapeXml = (text) =>
298
56
  text.replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[char] ?? char)
@@ -382,6 +140,16 @@ function sha256(file) {
382
140
  })
383
141
  }
384
142
 
143
+ /**
144
+ * A file's CRC-32, read as a stream.
145
+ * @param {string} file
146
+ */
147
+ async function fileCrc32(file) {
148
+ let crc = 0
149
+ for await (const chunk of createReadStream(file)) crc = crc32(chunk, crc)
150
+ return crc
151
+ }
152
+
385
153
  /**
386
154
  * Every file under a folder, as zip paths relative to it, sorted.
387
155
  * @param {string} dir
@@ -432,13 +200,13 @@ export async function buildScorm({ h5p, out, force = false }) {
432
200
  if (targetInfo && !force) throw new EmbedError(`${out} exists. Pass --force to replace it.`)
433
201
 
434
202
  const packDir = dirname(installed('@missing-elements/h5p-libraries/parts/index.json', 'Reinstall this package.'))
435
- const pack = packLibraries(JSON.parse(await readFile(join(packDir, 'index.json'), 'utf8')))
203
+ const pack = packLibraries(JSON.parse(await readFile(join(packDir, 'index.json'), 'utf8')), packDir)
436
204
 
437
205
  // What the package declares and carries, and what the pack adds, read before anything is written.
438
206
  /** @type {any} `h5p.json`, parsed */
439
207
  let manifest
440
- /** @type {ReturnType<typeof planParts>} */
441
- let plan
208
+ /** @type {typeof pack} the parts of the pack the zip carries, by folder */
209
+ let parts
442
210
  let reader
443
211
  try {
444
212
  reader = new ZipReader(new BlobReader(await openAsBlob(source)))
@@ -462,22 +230,39 @@ export async function buildScorm({ h5p, out, force = false }) {
462
230
  throw new EmbedError(`${basename(h5p)}: h5p.json names no libraries, so there is nothing to play.`)
463
231
  }
464
232
  const params = await readJson('content/content.json')
465
- const roots = [...dependenciesIn(manifest), ...librariesNamedIn(params)]
466
- plan = planParts({ own: await ownLibraries(byName.keys(), readJson), roots, params, pack })
233
+ /** @param {{ machineName: string, majorVersion: string | number, minorVersion: string | number }} dependency */
234
+ const numbered = ({ machineName, majorVersion, minorVersion }) => ({ machineName, majorVersion: Number(majorVersion), minorVersion: Number(minorVersion) })
235
+ const roots = [...dependenciesIn(manifest), ...librariesNamedIn(params)].map(numbered)
236
+ const folders = [...byName.keys()].flatMap((name) => {
237
+ const match = /^([^/]+)\/library\.json$/.exec(name)
238
+ return match ? [match[1]] : []
239
+ })
240
+ // The player's own plan, as the launch page has the element run it: gaps filled from the pack,
241
+ // nothing provided before, no refresh (`refresh-libraries="off"`).
242
+ const plan = planPack({ own: await ownLibraries(folders, (folder) => readJson(`${folder}/library.json`)), provided: [], roots, params, pack, refresh: false, fill: true })
243
+ const byFolder = new Map(pack.map((library) => [library.folder, library]))
244
+ parts = (plan?.parts ?? []).map((part) => /** @type {typeof pack[number]} */ (byFolder.get(part.folder))).sort((a, b) => (a.folder < b.folder ? -1 : a.folder > b.folder ? 1 : 0))
467
245
 
468
246
  // Held to its libraries as the player holds it once the parts are attached: the folders they
469
247
  // replace withheld, and each part's `library.json` naming what its record in the index does.
470
- const parts = new Map(plan.parts.map((part) => [`${part.folder}/library.json`, part.entry]))
471
- const withheld = new Set(plan.superseded)
248
+ const records = new Map(parts.map((part) => [`${part.folder}/library.json`, part.record]))
249
+ const withheld = new Set(plan?.superseded ?? [])
472
250
  const names = new Set([...byName.keys()].filter((name) => !withheld.has(name.split('/')[0])))
473
- for (const name of parts.keys()) names.add(name)
474
- const missing = await missingLibraries(roots, names, async (name) => parts.get(name) ?? readJson(name))
251
+ for (const name of records.keys()) names.add(name)
252
+ const available = indexLibraryFolders(names)
253
+ const { missing } = await walkDependencies(
254
+ roots,
255
+ (dependency) => reachableFolder(dependency, names, available),
256
+ async (folder) => records.get(`${folder}/library.json`) ?? readJson(`${folder}/library.json`)
257
+ )
475
258
  if (missing.length > 0) {
476
- const packed = new Map(pack.map((library) => [`${library.machineName} ${library.major}`, library]))
477
- const listed = missing.map(({ machineName, major, minor }) => {
478
- const older = packed.get(`${machineName} ${major}`)
479
- return `${machineName} ${major}.${minor}${older ? ` (the pack has ${older.major}.${older.minor})` : ''}`
480
- })
259
+ const packed = new Map(pack.map((library) => [`${library.machineName} ${library.majorVersion}`, library]))
260
+ const listed = missing
261
+ .map(({ machineName, majorVersion, minorVersion }) => {
262
+ const older = packed.get(`${machineName} ${Number(majorVersion)}`)
263
+ return `${machineName} ${majorVersion}.${minorVersion}${older ? ` (the pack has ${older.majorVersion}.${older.minorVersion})` : ''}`
264
+ })
265
+ .sort()
481
266
  throw new EmbedError(
482
267
  `${basename(h5p)} needs libraries that neither it nor the library pack carries: ${listed.join(', ')}. ` +
483
268
  'Export the package with its libraries, as Lumi and the H5P plugins of Moodle, WordPress and Drupal do.'
@@ -495,12 +280,12 @@ export async function buildScorm({ h5p, out, force = false }) {
495
280
  const title = named || basename(h5p, extname(h5p))
496
281
  // The learner's saved place is kept against the libraries too, as the player keeps it against
497
282
  // the revision: a zip written with others starts learners over, as a refreshed package does.
498
- const hashes = [await sha256(source), ...plan.parts.map((part) => part.sha256)]
283
+ const hashes = [await sha256(source), ...parts.map((part) => part.sha256)]
499
284
  const build = (hashes.length === 1 ? hashes[0] : createHash('sha256').update(hashes.join('\n')).digest('hex')).slice(0, 16)
500
285
  const player = dirname(installed('@missing-elements/h5p-offline-player/dist/h5p-player.js', 'Run `pnpm build` in the workspace, or reinstall this package.'))
501
286
  const runtime = dirname(installed('@missing-elements/h5p-runtime/dist/h5p.css', 'Run `pnpm build` in the workspace, or reinstall this package.'))
502
287
  const version = (await readFile(join(player, 'VERSION'), 'utf8').catch(() => 'unknown')).trim()
503
- const packVersion = plan.parts.length > 0 ? String(JSON.parse(await readFile(join(dirname(packDir), 'package.json'), 'utf8')).version) : null
288
+ const packVersion = parts.length > 0 ? String(JSON.parse(await readFile(join(dirname(packDir), 'package.json'), 'utf8')).version) : null
504
289
 
505
290
  const page = (await readFile(join(SCORM, 'index.html'), 'utf8'))
506
291
  .replace('%LANG%', languageTag(manifest.defaultLanguage))
@@ -520,8 +305,8 @@ export async function buildScorm({ h5p, out, force = false }) {
520
305
  [PACKAGE, { file: source }]
521
306
  ]
522
307
  if (packVersion) {
523
- files.push([`${LIBRARIES}/index.json`, { text: `${JSON.stringify({ libraries: plan.parts.map((part) => part.entry) }, null, 2)}\n` }])
524
- for (const part of plan.parts) files.push([`${LIBRARIES}/${part.folder}.h5p`, { file: join(packDir, `${part.folder}.h5p`) }])
308
+ files.push([`${LIBRARIES}/index.json`, { text: `${JSON.stringify({ libraries: parts.map((part) => part.record) }, null, 2)}\n` }])
309
+ for (const part of parts) files.push([`${LIBRARIES}/${part.folder}.h5p`, { file: join(packDir, `${part.folder}.h5p`) }])
525
310
  files.push(['libraries.txt', { file: join(dirname(packDir), 'libraries.txt') }])
526
311
  }
527
312
  files.push(['NOTICE.txt', { text: notice({ version, source: basename(h5p), pack: packVersion }) }])
@@ -533,9 +318,20 @@ export async function buildScorm({ h5p, out, force = false }) {
533
318
  const zip = new ZipWriter(Writable.toWeb(createWriteStream(partial)))
534
319
  await zip.add('imsmanifest.xml', new TextReader(xml))
535
320
  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 })
321
+ if ('text' in content) {
322
+ await zip.add(name, new TextReader(content.text), { level: 6 })
323
+ continue
324
+ }
325
+ const blob = await openAsBlob(content.file)
326
+ // The package, the pack's parts and the fonts are compressed already, so they are stored.
327
+ // Written into a stream, an entry gets a data descriptor, and a Java ZipInputStream refuses
328
+ // a stored entry that has one: SCORM Cloud refused exactly those (#92). Passed through with
329
+ // its size and CRC, it gets a complete local header instead, and is still streamed, not held.
330
+ if (name.endsWith('.h5p') || name.endsWith('.woff2')) {
331
+ await zip.add(name, new BlobReader(blob), { passThrough: true, compressionMethod: 0, uncompressedSize: blob.size, crc32: await fileCrc32(content.file) })
332
+ } else {
333
+ await zip.add(name, new BlobReader(blob), { level: 6 })
334
+ }
539
335
  }
540
336
  await zip.close()
541
337
  await rename(partial, target)
@@ -544,5 +340,5 @@ export async function buildScorm({ h5p, out, force = false }) {
544
340
  throw error
545
341
  }
546
342
 
547
- return { out: target, title, version, libraries: plan.parts.map((part) => part.folder), build, size: (await stat(target)).size }
343
+ return { out: target, title, version, libraries: parts.map((part) => part.folder), build, size: (await stat(target)).size }
548
344
  }
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.2",
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": {
@@ -60,7 +60,8 @@
60
60
  "dependencies": {
61
61
  "@zip.js/zip.js": "^2.24.0",
62
62
  "@missing-elements/h5p-libraries": "^1.0.0",
63
- "@missing-elements/h5p-offline-player": "^1.2.0",
63
+ "@missing-elements/h5p-normalize": "^1.2.0",
64
+ "@missing-elements/h5p-offline-player": "1.3.0",
64
65
  "@missing-elements/h5p-runtime": "^1.0.0"
65
66
  },
66
67
  "devDependencies": {
@@ -75,6 +76,6 @@
75
76
  "scripts": {
76
77
  "test": "pnpm --filter @missing-elements/h5p-offline-player build && vitest run",
77
78
  "typecheck": "pnpm types && tsc -p tsconfig.json",
78
- "types": "node -e \"require('node:fs').rmSync('types', { recursive: true, force: true })\" && tsc -p tsconfig.types.json"
79
+ "types": "pnpm --filter @missing-elements/h5p-offline-player exec tsc -b && node -e \"require('node:fs').rmSync('types', { recursive: true, force: true })\" && tsc -p tsconfig.types.json"
79
80
  }
80
81
  }
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