diff --git a/server/src/model/book.spec.ts b/server/src/model/book.spec.ts index 65ead91f..792973d7 100644 --- a/server/src/model/book.spec.ts +++ b/server/src/model/book.spec.ts @@ -11,7 +11,7 @@ describe('Book validations', () => { { title: chapterTitle, children: [] }, { title: chapterTitle, children: [] } ] - book.load(bookMaker({ toc })) + book.load(bookMaker({ toc, slug: 'test' })) expectErrors(book, [BookValidationKind.DUPLICATE_CHAPTER_TITLE, BookValidationKind.DUPLICATE_CHAPTER_TITLE]) }) it(BookValidationKind.MISSING_PAGE.title, () => { @@ -28,11 +28,22 @@ describe('Book validations', () => { { title: 'Chapter 1', children: ['m00001'] }, { title: 'Chapter 2', children: ['m00001'] } ] - book.load(bookMaker({ toc })) + book.load(bookMaker({ toc, slug: 'test' })) const page = first(book.pages) page.load(pageMaker({})) expectErrors(book, [BookValidationKind.DUPLICATE_PAGE, BookValidationKind.DUPLICATE_PAGE]) }) + it(BookValidationKind.INVALID_BOOK_NAME.title, () => { + const bundle = makeBundle() + const book = first(loadSuccess(bundle).books) + const toc: BookMakerTocNode[] = [ + { title: 'Chapter 1', children: ['m00001'] } + ] + book.load(bookMaker({ toc, slug: 'not-test' })) + const page = first(book.pages) + page.load(pageMaker({})) + expectErrors(book, [BookValidationKind.INVALID_BOOK_NAME]) + }) }) describe('Book computed properties', () => { diff --git a/server/src/model/book.ts b/server/src/model/book.ts index da4505f9..b33519af 100644 --- a/server/src/model/book.ts +++ b/server/src/model/book.ts @@ -1,7 +1,7 @@ import I from 'immutable' import * as Quarx from 'quarx' import { type PageNode } from './page' -import { type Opt, type WithRange, textWithRange, select, selectOne, findDuplicates, calculateElementPositions, expectValue, type HasRange, join, equalsOpt, equalsWithRange, tripleEq, equalsPos, equalsArray, PathKind, TocNodeKind } from './utils' +import { type Opt, type WithRange, textWithRange, select, selectOne, findDuplicates, calculateElementPositions, expectValue, type HasRange, join, equalsOpt, equalsWithRange, tripleEq, equalsPos, equalsArray, PathKind, TocNodeKind, NOWHERE } from './utils' import { Fileish, type ValidationCheck, ValidationKind } from './fileish' import { getCCLicense } from './cc-license' @@ -157,12 +157,21 @@ export class BookNode extends Fileish { message: BookValidationKind.DUPLICATE_PAGE, nodesToLoad: I.Set(), fn: () => I.Set(pageLeaves.filter(p => duplicatePages.has(p.page)).map(l => l.range)) + }, + { + message: BookValidationKind.INVALID_BOOK_NAME, + nodesToLoad: I.Set(), + fn: () => this.absPath.endsWith(`${this.slug}.collection.xml`) + ? I.Set() + : I.Set([NOWHERE]) } ] } } export class BookValidationKind extends ValidationKind { + // openstax/enki/bakery-js/src/epub/toc.tsx#L74 + static INVALID_BOOK_NAME = new BookValidationKind('Book must be named .collection.xml') static MISSING_PAGE = new BookValidationKind('Missing Page') static DUPLICATE_CHAPTER_TITLE = new BookValidationKind('Duplicate chapter title') static DUPLICATE_PAGE = new BookValidationKind('Duplicate page') diff --git a/server/src/model/bundle.spec.ts b/server/src/model/bundle.spec.ts index 191b43a4..8537b7b7 100644 --- a/server/src/model/bundle.spec.ts +++ b/server/src/model/bundle.spec.ts @@ -1,6 +1,6 @@ import { expect } from '@jest/globals' import { type Bundle, BundleValidationKind } from './bundle' -import { bundleMaker, expectErrors, first, loadSuccess, makeBundle, read } from './spec-helpers.spec' +import { bookMaker, bundleMaker, expectErrors, first, loadSuccess, makeBundle, read } from './spec-helpers.spec' describe('Bundle validations', () => { it(BundleValidationKind.NO_BOOKS.title, () => { @@ -14,6 +14,12 @@ describe('Bundle validations', () => { book.load(undefined) expectErrors(bundle, [BundleValidationKind.MISSING_BOOK]) }) + it(BundleValidationKind.MISMATCHED_SLUG.title, () => { + const bundle = loadSuccess(makeBundle()) + const book = first(bundle.books) + book.load(bookMaker({ slug: 'something' })) + expectErrors(bundle, [BundleValidationKind.MISMATCHED_SLUG]) + }) }) describe('Happy path', () => { diff --git a/server/src/model/bundle.ts b/server/src/model/bundle.ts index dc50a0b9..3dd7b47c 100644 --- a/server/src/model/bundle.ts +++ b/server/src/model/bundle.ts @@ -14,6 +14,7 @@ export class Bundle extends Fileish implements Bundleish { public readonly allH5P: Factory = new Factory((absPath: string) => new H5PExercise(this, this.pathHelper, absPath), (x) => this.pathHelper.canonicalize(x)) public readonly allBooks = new Factory((absPath: string) => new BookNode(this, this.pathHelper, absPath), (x) => this.pathHelper.canonicalize(x)) private readonly _books = Quarx.observable.box>>>(undefined) + private readonly _booksXMLBooks = Quarx.observable.box>>>(undefined) private readonly _duplicateFilePaths = Quarx.observable.box>(I.Set()) private readonly _duplicateUUIDs = Quarx.observable.box>(I.Set()) // TODO: parse these from META-INF/books.xml @@ -52,9 +53,17 @@ export class Bundle extends Fileish implements Bundleish { protected parseXML = (doc: Document) => { const bookNodes = select('//bk:book', doc) as Element[] - this._books.set(I.Set(bookNodes.map(b => { + const booksXMLBooks = I.Set(bookNodes.map((b => { const range = calculateElementPositions(b) const href = expectValue(b.getAttribute('href'), 'ERROR: Missing @href attribute on book element') + const slug = expectValue(b.getAttribute('slug'), 'ERROR: Missing @slug attribute on book element') + const v: BooksXMLBook = { slug, href } + return { v, range } + }))) + this._booksXMLBooks.set(booksXMLBooks) + this._books.set(I.Set(booksXMLBooks.map(b => { + const range = b.range + const href = b.v.href const book = this.allBooks.getOrAdd(join(this.pathHelper, PathKind.ABS_TO_REL, this.absPath, href)) return { v: book, @@ -90,6 +99,7 @@ export class Bundle extends Fileish implements Bundleish { protected getValidationChecks(): ValidationCheck[] { const books = this.__books() + const booksXMLBooks = this.ensureLoaded(this._booksXMLBooks) return [ { message: BundleValidationKind.MISSING_BOOK, @@ -100,12 +110,39 @@ export class Bundle extends Fileish implements Bundleish { message: BundleValidationKind.NO_BOOKS, nodesToLoad: I.Set(), fn: () => books.isEmpty() ? I.Set([NOWHERE]) : I.Set() + }, + { + message: BundleValidationKind.MISMATCHED_SLUG, + nodesToLoad: this.books, + fn: () => { + return booksXMLBooks + .filter(({ v: bx, range: rx }) => { + const maybeBookNode = books.find( + ({ range: r }) => r.start === rx.start && r.end === rx.end + ) + if (maybeBookNode !== undefined) { + const { v: book } = maybeBookNode + if (book.isValidXML && book.exists) { + return book.slug !== bx.slug + } + } + return false + }) + .map(({ range }) => range) + } } ] } } export class BundleValidationKind extends ValidationKind { + // openstax/enki/bakery-src/scripts/link_single.py#L59 + static MISMATCHED_SLUG = new BundleValidationKind('Slug does not match the one defined in the book') static MISSING_BOOK = new BundleValidationKind('Missing book') static NO_BOOKS = new BundleValidationKind('No books defined') } + +interface BooksXMLBook { + slug: string + href: string +}