From bea1c5f18165a9741dc7ece766c201d9a14bc6b7 Mon Sep 17 00:00:00 2001 From: Thomas Taylor Date: Thu, 25 Jun 2026 21:06:35 +0100 Subject: [PATCH] Fix: Recover from unresolvable _start ids on import (fixes #220) When a course's _start._startIds references content not present in the import, the StartPage transform previously left the dangling id in place, producing a course that loads indefinitely (the player cannot find a start page). Recover gracefully: if the course has exactly one page, default the start to it; otherwise remove the _start config so the menu loads. A warning is logged in both cases and resolvable start ids are unchanged. --- lib/migrations/start-page.js | 44 +++++++++++++++---------- tests/migrations.spec.js | 62 ++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 16 deletions(-) diff --git a/lib/migrations/start-page.js b/lib/migrations/start-page.js index 5eb6116..3bdb5b3 100644 --- a/lib/migrations/start-page.js +++ b/lib/migrations/start-page.js @@ -1,16 +1,28 @@ -async function StartPage (data, importer) { - if (data._type !== 'course' || data._start === undefined) { - return - } - let pageIndex = 1 - data._start._startIds.forEach((d, i) => { - const _id = data._start._startIds[i]._id - const co = importer.contentJson.contentObjects[_id] - if (!co) { - return importer.framework.log('warn', `StartPage transform: unable to find content with _id '${_id}'`) - } - co._friendlyId = data._start._startIds[i]._id = co._friendlyId ?? `start_page_${pageIndex++}` - }) -} - -export default StartPage +async function StartPage (data, importer) { + if (data._type !== 'course' || data._start === undefined) { + return + } + const pages = Object.values(importer.contentJson.contentObjects).filter(c => c._type === 'page') + let pageIndex = 1 + for (let i = 0; i < data._start._startIds.length; i++) { + const _id = data._start._startIds[i]._id + let co = importer.contentJson.contentObjects[_id] + if (!co) { + // A start id referencing content missing from the import would leave the + // course unable to route to its start page (the player loads indefinitely). + if (pages.length === 1) { + // Single-page course: fall back to that page. + co = pages[0] + importer.framework.log('warn', `StartPage transform: unable to find content with _id '${_id}'; defaulting to the course's only page '${co._id}'`) + } else { + // Can't safely choose a start page: remove the start config so the menu loads. + importer.framework.log('warn', `StartPage transform: unable to find content with _id '${_id}'; removing _start config so the menu is loaded`) + delete data._start + return + } + } + co._friendlyId = data._start._startIds[i]._id = co._friendlyId ?? `start_page_${pageIndex++}` + } +} + +export default StartPage diff --git a/tests/migrations.spec.js b/tests/migrations.spec.js index 19f850d..ed7eb1c 100644 --- a/tests/migrations.spec.js +++ b/tests/migrations.spec.js @@ -336,6 +336,68 @@ describe('Migrations', () => { await StartPage(data, importer) assert.equal(data._start._startIds[0]._id, 'start_page_1') }) + + it('should fall back to the only page when a start id is missing in a single-page course', async () => { + const logFn = mock.fn() + const data = { + _type: 'course', + _start: { _startIds: [{ _id: 'missing' }] } + } + const importer = { + contentJson: { + contentObjects: { + 'co-1': { _id: 'co-1', _type: 'page', _friendlyId: 'p-1' } + } + }, + framework: { log: logFn } + } + await StartPage(data, importer) + // start id rewritten to the single page's friendlyId; _start kept + assert.ok(data._start) + assert.equal(data._start._startIds[0]._id, 'p-1') + assert.equal(logFn.mock.calls.length, 1) + assert.equal(logFn.mock.calls[0].arguments[0], 'warn') + }) + + it('should assign a generated friendlyId when falling back to a single page without one', async () => { + const data = { + _type: 'course', + _start: { _startIds: [{ _id: 'missing' }] } + } + const importer = { + contentJson: { + contentObjects: { + 'co-1': { _id: 'co-1', _type: 'page' } + } + }, + framework: { log: mock.fn() } + } + await StartPage(data, importer) + assert.equal(importer.contentJson.contentObjects['co-1']._friendlyId, 'start_page_1') + assert.equal(data._start._startIds[0]._id, 'start_page_1') + }) + + it('should remove _start config when a start id is missing and there is not exactly one page', async () => { + const logFn = mock.fn() + const data = { + _type: 'course', + _start: { _startIds: [{ _id: 'missing' }] } + } + const importer = { + contentJson: { + contentObjects: { + 'co-1': { _id: 'co-1', _type: 'page', _friendlyId: 'p-1' }, + 'co-2': { _id: 'co-2', _type: 'page', _friendlyId: 'p-2' } + } + }, + framework: { log: logFn } + } + await StartPage(data, importer) + // can't safely choose a start page → drop _start so the menu loads + assert.equal(data._start, undefined) + assert.equal(logFn.mock.calls.length, 1) + assert.equal(logFn.mock.calls[0].arguments[0], 'warn') + }) }) describe('ThemeUndef', () => {