/* ===================================================================== Exercise builder — document model One JSON tree is the source of truth. Every container holds an ordered `children` array, which is what makes drag-and-drop a single operation: move a node from one children array into another. Node kinds part major step track: "core" | "side" task minor step container; only ever inside a part prose paragraph inline markup, see inline.js cmd command box terminal transcript typed lines protip keystroke tip sidequest LEGACY only inline optional block; see accepts() callout emphasis block container note quiet aside container table data table list bullet list ===================================================================== */ export const CONTAINERS = new Set(['part', 'task', 'sidequest', 'callout', 'note']); export const PALETTE = [ // The sidebar mirrors the shape of a sheet: the three categories from the // legend first, then the step that lives inside them, then the pieces. { kind: 'part', preset: { track: 'core' }, group: 'Structure', label: 'Core', hint: 'An examinable section' }, { kind: 'part', preset: { track: 'side' }, group: 'Structure', label: 'Side quest', hint: 'An optional section' }, { kind: 'protip', group: 'Structure', label: 'Protip', hint: 'A quick tip' }, { kind: 'task', group: 'Structure', label: 'Task', hint: 'A numbered step' }, { kind: 'callout', group: 'Containers', label: 'Callout', hint: 'Emphasised block' }, { kind: 'note', group: 'Containers', label: 'Note', hint: 'Quiet aside' }, { kind: 'prose', group: 'Content', label: 'Prose', hint: 'A paragraph' }, { kind: 'list', group: 'Content', label: 'Bullet list', hint: 'Plain bullets' }, { kind: 'table', group: 'Content', label: 'Table', hint: 'Rows and columns' }, { kind: 'cmd', group: 'Content', label: 'Command', hint: 'Click-to-copy box' }, { kind: 'terminal', group: 'Content', label: 'Terminal', hint: 'Session transcript' }, ]; /** The wording of the three categories. One source of truth. */ export const DEFAULT_LEGEND = { core: 'Material from the lecture. This is what is examinable. You should complete all of these sections.', side: 'Not examinable. Useful Information worth knowing, but safe to skip if you are short on time.', protip: 'A quick tip to save you time', }; let seq = 0; export function uid(prefix = 'n') { seq += 1; return `${prefix}${Date.now().toString(36)}${seq.toString(36)}`; } /* ---------- construction ---------- */ export function blank(kind) { const id = uid(kind[0]); switch (kind) { case 'part': return { id, kind, track: 'core', title: 'New part', children: [] }; case 'task': return { id, kind, children: [blank('prose')] }; case 'prose': return { id, kind, text: 'New paragraph.', variant: null }; case 'cmd': return { id, kind, command: 'pwd' }; case 'terminal': // a line is a list of typed segments, because real transcripts mix // prompt, command and output on one line return { id, kind, caption: '', lines: [ [{ role: 'p', text: '$' }, { role: null, text: ' ' }, { role: 'c', text: 'pwd' }], [{ role: 'o', text: '/home/you' }], ]}; case 'protip': return { id, kind, keys: ['Tab'], label: 'Protip', text: 'What this saves you.', emphasis: false }; case 'sidequest': return { id, kind, summary: 'Optional extra', children: [blank('prose')] }; case 'callout': return { id, kind, heading: 'Heading', children: [blank('prose')] }; case 'note': return { id, kind, children: [blank('prose')] }; case 'table': return { id, kind, columns: ['Column', 'Column'], rows: [['', '']] }; case 'list': return { id, kind, items: ['First item'] }; default: throw new Error(`unknown kind: ${kind}`); } } export function emptyDoc() { return { meta: { courseCode: 'BI122G', courseName: 'Introduction to Computational Tools for Bioinformatics', term: 'Autumn 2026', exercise: 'Exercise 1', title: 'Untitled', standfirst: 'In this exercise we will…', logo: '', legendText: { ...DEFAULT_LEGEND }, }, intro: [], parts: [], closing: [], }; } /* ---------- traversal ---------- */ /** Every array of nodes in the document, keyed so a node can be located. */ export function walk(doc, visit) { const lists = [ { list: doc.intro, owner: null, field: 'intro' }, { list: doc.parts, owner: null, field: 'parts' }, { list: doc.closing, owner: null, field: 'closing' }, ]; while (lists.length) { const { list, owner, field } = lists.shift(); list.forEach((node, index) => { visit(node, list, index, owner, field); if (Array.isArray(node.children)) { lists.push({ list: node.children, owner: node, field: 'children' }); } }); } } export function find(doc, id) { let hit = null; walk(doc, (node, list, index, owner) => { if (node.id === id) hit = { node, list, index, owner }; }); return hit; } /** Would moving `id` into `targetList` put a node inside itself? */ function wouldOrphan(doc, id, targetList) { const found = find(doc, id); if (!found) return true; let inside = false; const scan = (node) => { if (node.children === targetList) inside = true; (node.children || []).forEach(scan); }; scan(found.node); return inside; } /* ---------- mutation ---------- */ export function insert(list, node, index) { list.splice(index == null ? list.length : index, 0, node); return node; } export function remove(doc, id) { const found = find(doc, id); if (!found) return null; found.list.splice(found.index, 1); return found.node; } export function move(doc, id, targetList, index) { if (wouldOrphan(doc, id, targetList)) return false; const found = find(doc, id); if (!found) return false; const sameList = found.list === targetList; found.list.splice(found.index, 1); let at = index == null ? targetList.length : index; if (sameList && found.index < at) at -= 1; targetList.splice(at, 0, found.node); return true; } export function nudge(doc, id, delta) { const found = find(doc, id); if (!found) return false; const to = found.index + delta; if (to < 0 || to >= found.list.length) return false; const [node] = found.list.splice(found.index, 1); found.list.splice(to, 0, node); return true; } export function duplicate(doc, id) { const found = find(doc, id); if (!found) return null; const copy = reid(JSON.parse(JSON.stringify(found.node))); found.list.splice(found.index + 1, 0, copy); return copy; } /** A deep copy carrying fresh ids, subcomponents and all. */ export function cloneFresh(node) { return reid(JSON.parse(JSON.stringify(node))); } function reid(node) { node.id = uid(node.kind[0]); (node.children || []).forEach(reid); return node; } /* ---------- numbering ---------- */ /** Core parts number 1..n; side quests are not part of the sequence. */ export function partNumbers(doc) { const map = new Map(); let n = 0; doc.parts.forEach((part) => { if (part.track !== 'side') { n += 1; map.set(part.id, n); } }); return map; } /** * Whether a node may legally accept a child of this kind. * * A side quest is a PART with track "side" — it never nests inside a core * part. The `sidequest` node kind is retained only so that sheets written * before that rule still import and render; it is absent from the palette, * so nothing new can be built with it. */ export function accepts(parentKind, childKind) { if (childKind === 'sidequest') return false; // legacy, never placeable if (childKind === 'task') return parentKind === 'part'; if (childKind === 'part') return parentKind === 'root'; if (parentKind === 'root') return childKind === 'part'; return CONTAINERS.has(parentKind); } /** * Fold away shapes from earlier versions of the model: * a `tasks` wrapper node becomes its task children, sitting directly in the * part. The