// The checking pass of applyOps — core/src/ops.rs's Model and the // vocabularies it closes. // // A Yjs transaction cannot be rolled back, so "a failure applies // nothing" has to be true before the first write: every op, once parsed // (ops-parse.mjs), is checked against a model of the deck — the slide // ids, their block ids and types, the asset ids — that each op updates // as it is checked, so a reference that only becomes wrong halfway // through a batch (remove_block on a block an earlier op removed) is // caught here too. The messages are the core's, word for word, so the // inspector and the drive API say the same thing about the same mistake. import { getString, getArray, getObject, orderedMaps } from './ydoc.mjs' export { parseOp } from './ops-parse.mjs' export const LAYOUTS = ['flow', 'canvas'] export const TEMPLATES = ['title', 'title-body', 'two-column', 'section', 'image-full', 'quote', 'blank'] export const REGIONS = ['heading', 'body', 'aside', 'media'] export const BACKGROUND_KINDS = ['theme', 'color', 'image'] export const SLIDE_PHONES = ['inherit', 'reflow'] export const TRANSITIONS = ['none', 'fade'] export const BLOCK_TYPES = ['heading', 'text', 'list', 'quote', 'image', 'columns', 'code', 'shape', 'table', 'divider', 'spacer'] export const TEXT_TYPES = ['heading', 'text', 'list', 'quote', 'code', 'shape'] export const SIZES = ['s', 'm', 'l'] export const MAX_TABLE_SIDE = 100 export const isTextType = kind => TEXT_TYPES.includes(kind) export class OpsError extends Error { constructor (message, index) { super(message) this.name = 'OpsError' this.index = index } } const fail = msg => { throw new Error(msg) } const isInt = v => Number.isInteger(v) function inVocab (what, value, vocab) { if (!vocab.includes(value)) fail(`${what} ${JSON.stringify(value)} is not one of ${vocab.join(', ')}`) } function nonEmpty (what, value) { if (value === '') fail(`${what} is empty`) } /** rows/cols from props when present: whole numbers within 1..=MAX_TABLE_SIDE. */ export function tableSide (props, key) { const v = props[key] if (v === undefined) return null if (!isInt(v) || v < 1 || v > MAX_TABLE_SIDE) fail(`table ${key} must be a whole number from 1 to ${MAX_TABLE_SIDE}`) return v } function blockIndex (s, id) { const i = s.blocks.findIndex(b => b.id === id) if (i === -1) fail(`no block ${JSON.stringify(id)} in slide ${JSON.stringify(s.id)}`) return i } /** * A columns block's props.children (spec §2.4): block ids, each naming * a block on s other than selfId, each named once, none already a child * of another columns block, and none with selfId underneath it — the * children of a slide are a forest, one parent per block and no way * back up (codex 7). null clears. The messages are core/src/ops.rs's, * word for word. */ function validateChildren (s, selfId, value) { if (value === null) return [] if (!Array.isArray(value)) fail('columns children must be an array of block ids') const out = [] for (const cid of value) { if (typeof cid !== 'string') fail('columns children must be an array of block ids') if (cid === selfId) fail(`columns ${JSON.stringify(selfId)} cannot name itself in children`) if (!s.blocks.some(b => b.id === cid)) fail(`columns children names ${JSON.stringify(cid)}, not a block on slide ${JSON.stringify(s.id)}`) if (out.includes(cid)) fail(`columns children names ${JSON.stringify(cid)} twice`) const other = s.blocks.find(b => b.id !== selfId && b.children !== null && b.children.includes(cid)) if (other) fail(`columns children names ${JSON.stringify(cid)}, already a child of columns ${JSON.stringify(other.id)}`) if (contains(s, cid, selfId)) fail(`columns children names ${JSON.stringify(cid)}, which would put columns ${JSON.stringify(selfId)} inside itself`) out.push(cid) } return out } /** Whether target is root or sits anywhere under it, following the slide's columns children as the model holds them. */ function contains (s, root, target) { const stack = [root] const seen = new Set() while (stack.length) { const id = stack.pop() if (id === target) return true if (seen.has(id)) continue seen.add(id) const b = s.blocks.find(x => x.id === id) if (b && b.children !== null) stack.push(...b.children) } return false } export class Model { static read (r) { const m = new Model() m.slides = orderedMaps(r.slides).map(([, sm]) => { const blocks = getArray(sm, 'blocks') // Reading order: after and duplicate_slide's new_block_ids are // positional, and the position that means anything is the one a // reader of the view sees. return { id: getString(sm, 'id') ?? '', blocks: blocks === null ? [] : orderedMaps(blocks).map(([, b]) => { const kind = getString(b, 'type') ?? '' let children = null if (kind === 'columns') { const c = (getObject(b, 'props') ?? {}).children children = Array.isArray(c) ? c.filter(x => typeof x === 'string') : [] } return { id: getString(b, 'id') ?? '', kind, children } }) } }) m.assets = new Set() r.assets.forEach((_, k) => m.assets.add(k)) return m } slideIndex (id) { const i = this.slides.findIndex(s => s.id === id) if (i === -1) fail(`no slide ${JSON.stringify(id)}`) return i } hasSlide (id) { return this.slides.some(s => s.id === id) } slide (id) { return this.slides[this.slideIndex(id)] } check (op) { switch (op.op) { case 'set_meta': return case 'add_slide': { nonEmpty('id', op.id) if (this.hasSlide(op.id)) fail(`slide ${JSON.stringify(op.id)} already exists`) inVocab('layout', op.layout, LAYOUTS) if (op.template !== undefined) inVocab('template', op.template, TEMPLATES) const at = op.after === undefined ? this.slides.length : op.after === null ? 0 : this.slideIndex(op.after) + 1 this.slides.splice(at, 0, { id: op.id, blocks: [] }) return } case 'remove_slide': this.slides.splice(this.slideIndex(op.id), 1) return case 'move_slide': { this.slideIndex(op.id) if (op.after !== undefined) { if (op.after === op.id) fail('move_slide: after is the slide itself') this.slideIndex(op.after) } const [s] = this.slides.splice(this.slideIndex(op.id), 1) const at = op.after === undefined ? 0 : this.slideIndex(op.after) + 1 this.slides.splice(at, 0, s) return } case 'duplicate_slide': { nonEmpty('new_id', op.new_id) const i = this.slideIndex(op.id) if (this.hasSlide(op.new_id)) fail(`slide ${JSON.stringify(op.new_id)} already exists`) const n = this.slides[i].blocks.length if (op.new_block_ids.length !== n) fail(`duplicate_slide: ${n} blocks but ${op.new_block_ids.length} new_block_ids`) if (new Set(op.new_block_ids).size !== n || op.new_block_ids.some(x => x === '')) fail('duplicate_slide: new_block_ids must be distinct and non-empty') const idMap = new Map(this.slides[i].blocks.map((b, k) => [b.id, op.new_block_ids[k]])) const blocks = this.slides[i].blocks.map((b, k) => ({ id: op.new_block_ids[k], kind: b.kind, children: b.children === null ? null : b.children.map(c => idMap.get(c) ?? c) })) this.slides.splice(i + 1, 0, { id: op.new_id, blocks }) return } case 'set_slide': this.slideIndex(op.id) if (op.layout !== undefined) inVocab('layout', op.layout, LAYOUTS) if (op.template !== undefined) inVocab('template', op.template, TEMPLATES) if (op.background !== undefined) inVocab('background.kind', op.background.kind, BACKGROUND_KINDS) if (op.phone !== undefined) inVocab('phone', op.phone, SLIDE_PHONES) if (op.transition !== undefined) inVocab('transition', op.transition, TRANSITIONS) return case 'set_notes': this.slideIndex(op.id) return case 'add_block': { nonEmpty('id', op.id) inVocab('type', op.type, BLOCK_TYPES) if (op.region !== undefined) inVocab('region', op.region, REGIONS) if (op.type === 'table') { tableSide(op.props, 'rows'); tableSide(op.props, 'cols') } const s = this.slide(op.slide) if (s.blocks.some(b => b.id === op.id)) fail(`block ${JSON.stringify(op.id)} already exists in slide ${JSON.stringify(op.slide)}`) let children = null if (op.type === 'columns') children = op.props.children === undefined ? [] : validateChildren(s, op.id, op.props.children) const at = op.after === undefined ? s.blocks.length : op.after === null ? 0 : blockIndex(s, op.after) + 1 s.blocks.splice(at, 0, { id: op.id, kind: op.type, children }) return } case 'remove_block': { const s = this.slide(op.slide) s.blocks.splice(blockIndex(s, op.id), 1) for (const b of s.blocks) if (b.children !== null) b.children = b.children.filter(c => c !== op.id) return } case 'move_block': { const s = this.slide(op.slide) const i = blockIndex(s, op.id) if (op.after !== undefined) { if (op.after === op.id) fail('move_block: after is the block itself') blockIndex(s, op.after) } const [b] = s.blocks.splice(i, 1) const at = op.after === undefined ? 0 : blockIndex(s, op.after) + 1 s.blocks.splice(at, 0, b) return } case 'set_block': { const s = this.slide(op.slide) const i = blockIndex(s, op.id) if (typeof op.region === 'string') inVocab('region', op.region, REGIONS) if (s.blocks[i].kind === 'table' && op.props !== undefined) { tableSide(op.props, 'rows'); tableSide(op.props, 'cols') } if (s.blocks[i].kind === 'columns' && op.props !== undefined && op.props.children !== undefined) { s.blocks[i].children = validateChildren(s, op.id, op.props.children) } return } case 'set_text': { const s = this.slide(op.slide) const b = s.blocks[blockIndex(s, op.id)] if (!isTextType(b.kind)) fail(`block ${JSON.stringify(op.id)} is a ${b.kind} and carries no text`) return } case 'set_phone': { const s = this.slide(op.slide) blockIndex(s, op.id) if (op.phone.size !== undefined) inVocab('phone.size', op.phone.size, SIZES) return } case 'register_asset': nonEmpty('id', op.id) nonEmpty('ref', op.ref) if (this.assets.has(op.id)) fail(`asset ${JSON.stringify(op.id)} is already registered`) this.assets.add(op.id) return case 'remove_asset': if (!this.assets.delete(op.id)) fail(`no asset ${JSON.stringify(op.id)}`) } } }