import { Hocuspocus } from '@hocuspocus/server' import * as Y from 'yjs' import { yDocToProsemirrorJSON } from 'y-prosemirror' import { newId, b64encode, b64decode } from './util.js' import * as store from './store.js' import { pmToMarkdown, blockToMarkdown, markdownToBlocks } from './md.js' import { sessionFromCookieHeader, userFromToken, BUILD_ID } from './auth.js' import { DOC_NAME_RE, projectIdOf, pageSlugOf, docNameFor, slugify, PAGE_SLUG_RE } from './util.js' import { defaultStructureYaml, clearStructureCache, primeStructureCache } from './pages.js' const FIELD = 'default' const CLAIM_TIMEOUT_MS = 5 * 60 * 1000 const MAX_ATTEMPTS = 3 // --- mention queue state (persisted via store) --- const mentionState = store.getMentionState() const waiters = [] // { handles:Set, resolve, timer, done } const agentLastPoll = new Map() // handle -> ts const docWatchers = new Map() // docName -> unobserve fn function persistMentions() { store.saveMentionState(mentionState) } // --- hocuspocus server --- async function authenticate({ requestHeaders, token, documentName }) { if (documentName && !DOC_NAME_RE.test(documentName)) throw new Error('bad document name') let user = null if (token && token.startsWith('cookie')) { // browser clients carry their bundle's build id ("cookie:") — a stale // tab holding a diverged replica must NOT be allowed to sync: it re-inserts // long-deleted content into everyone's doc const clientBuild = token.split(':')[1] || null if (BUILD_ID !== 'dev' && clientBuild !== BUILD_ID) { throw new Error('outdated client — reload the page to get the current version') } } else if (token) { if (token.startsWith('ak_')) { const agent = store.agentByKey(token) if (agent) user = { username: agent.owner, name: agent.owner, avatar: null } } else { user = await userFromToken(token) } } if (!user) user = sessionFromCookieHeader(requestHeaders?.cookie || requestHeaders?.get?.('cookie')) if (!user) throw new Error('unauthorized') if (documentName && !store.canAccessDoc(documentName, user.username)) throw new Error('no access to this document') return { user } } export const hocuspocus = new Hocuspocus({ debounce: 2000, maxDebounce: 10000, async onAuthenticate(data) { return authenticate(data) }, async onLoadDocument({ documentName, document }) { const state = store.loadDocState(documentName) if (state) Y.applyUpdate(document, state) return document }, async afterLoadDocument({ documentName, document }) { attachWatcher(documentName, document) // catch up on anything added while the server was down setTimeout(() => scanDocForMentions(documentName, document), 0) // one-time upgrade of pre-v2 suggestions to item-based anchors so their // ranges stop drifting when neighbouring blocks are added. Must operate on // the ALREADY-LOADED document — opening a direct connection here would // re-load the doc after unload and loop forever. if (!migratedAnchorDocs.has(documentName)) { migratedAnchorDocs.add(documentName) setTimeout(() => { try { migrateLegacySuggestionAnchors(document) } catch {} try { migrateCheckboxLists(document) } catch {} // structure pages are just the yaml field now — drop old heading/intro try { if (String(documentName).endsWith('::_structure') && !document.isDestroyed) { const fragment = document.getXmlFragment(FIELD) let hasCode = false for (let i = 0; i < fragment.length; i++) { if (fragment.get(i) instanceof Y.XmlElement && fragment.get(i).nodeName === 'codeBlock') hasCode = true } if (hasCode) { document.transact(() => { for (let i = fragment.length - 1; i >= 0; i--) { const el2 = fragment.get(i) if (!(el2 instanceof Y.XmlElement) || el2.nodeName !== 'codeBlock') fragment.delete(i, 1) } }) } } } catch {} }, 50) } }, async afterUnloadDocument({ documentName }) { const un = docWatchers.get(documentName) if (un) { un() docWatchers.delete(documentName) } }, async onStoreDocument({ documentName, document }) { let markdown = null let title = null try { const pm = yDocToProsemirrorJSON(document, FIELD) markdown = pmToMarkdown(pm) title = titleFromPm(pm) } catch {} store.saveDocState(documentName, Y.encodeStateAsUpdate(document), markdown) store.touchProjectOnStore(projectIdOf(documentName), pageSlugOf(documentName), title) }, }) function titleFromPm(pm) { for (const node of pm.content || []) { const text = blockToMarkdown(node) ?.replace(/^#+\s*/, '') .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links -> their text .replace(/[*_`]/g, '') .trim() if (text) return text.slice(0, 80) } return null } // --- document watcher: detect new @mentions in comment threads --- function attachWatcher(docName, document) { if (docWatchers.has(docName)) return const threads = document.getMap('threads') const observer = () => setTimeout(() => scanDocForMentions(docName, document), 0) threads.observeDeep(observer) docWatchers.set(docName, () => threads.unobserveDeep(observer)) } function scanDocForMentions(docName, document) { if (document.isDestroyed) return const threads = document.getMap('threads') const agents = store.getAgents() const created = [] const explicitHandles = text => [...new Set([...String(text || '').matchAll(/@([a-z0-9][a-z0-9_.-]{1,38})/g)].map(m => m[1]))].filter(h => agents[h]) threads.forEach((ythread, threadId) => { const messages = ythread.get('messages') if (!messages) return const all = messages.toArray() all.forEach((msg, msgIdx) => { if (!msg?.id || msg.authorType === 'agent') return const key = `${docName}:${threadId}:${msg.id}` if (mentionState.processed[key]) return mentionState.processed[key] = true // an agent only receives work for docs its owner can access, and a // PRIVATE agent only answers mentions written by its owner const usable = h => store.canAccessDoc(docName, agents[h]?.owner) && (agents[h]?.shared !== false || agents[h]?.owner === msg.author) const explicit = explicitHandles(msg.text) let handles = explicit.filter(usable) let implicit = false // implicit routing only when the author tagged nobody — an explicit // mention of an unreachable agent must not be redirected to another one if (!explicit.length) { // No explicit tag: deliver to the ONE agent clearly involved in this // thread (it authored the suggestion under discussion, replied earlier, // or was mentioned earlier). Ambiguous (0 or 2+ candidates) => nobody. const candidates = new Set() const suggestionId = ythread.get('suggestionId') if (suggestionId) { const sugg = document.getMap('suggestions').get(suggestionId) if (sugg?.authorType === 'agent' && agents[sugg.author]) candidates.add(sugg.author) } for (const prev of all.slice(0, msgIdx)) { if (prev.authorType === 'agent' && agents[prev.author]) candidates.add(prev.author) for (const h of explicitHandles(prev.text)) candidates.add(h) } if (candidates.size === 1) { handles = [...candidates].filter(usable) implicit = true } } const chips = [] for (const handle of handles) { const task = { id: newId(12), docId: docName, threadId, messageId: msg.id, handle, requestedBy: msg.author, text: msg.text, implicit, status: 'pending', attempts: 0, ts: Date.now(), } mentionState.tasks[task.id] = task created.push(task) chips.push({ handle, mentionId: task.id, status: 'pending' }) } if (chips.length) { setTimeout(() => updateMessage(docName, threadId, msg.id, { mentions: chips }).catch(() => {}), 0) } }) }) if (created.length) { persistMentions() wakeWaiters() } } // --- privileged Y.Doc writes --- async function withDoc(docName, fn) { const conn = await hocuspocus.openDirectConnection(docName, { user: { username: '__server__' } }) try { let result await conn.transact(document => { result = fn(document) }) return result } finally { await conn.disconnect() } } async function readDoc(docName, fn) { const conn = await hocuspocus.openDirectConnection(docName, { user: { username: '__server__' } }) try { return fn(conn.document) } finally { await conn.disconnect() } } export async function updateMessage(docName, threadId, messageId, patch) { return withDoc(docName, document => { const ythread = document.getMap('threads').get(threadId) const messages = ythread?.get('messages') if (!messages) return false const idx = messages.toArray().findIndex(m => m?.id === messageId) if (idx === -1) return false const updated = { ...messages.get(idx), ...patch } messages.delete(idx, 1) messages.insert(idx, [updated]) return true }) } export async function addMessage(docName, threadId, { author, authorType, text }) { const msg = { id: newId(10), author, authorType, text, ts: Date.now() } const ok = await withDoc(docName, document => { const ythread = document.getMap('threads').get(threadId) const messages = ythread?.get('messages') if (!messages) return false messages.push([msg]) return true }) return ok ? msg : null } export async function setMentionChip(task, status) { try { const ythreadOk = await readDoc(task.docId, document => { const ythread = document.getMap('threads').get(task.threadId) const messages = ythread?.get('messages') if (!messages) return false return messages.toArray().some(m => m?.id === task.messageId) }) if (!ythreadOk) return await withDoc(task.docId, document => { const messages = document.getMap('threads').get(task.threadId).get('messages') const idx = messages.toArray().findIndex(m => m?.id === task.messageId) if (idx === -1) return const msg = { ...messages.get(idx) } msg.mentions = (msg.mentions || []).map(c => (c.mentionId === task.id ? { ...c, status } : c)) messages.delete(idx, 1) messages.insert(idx, [msg]) }) } catch {} } function setTaskStatus(task, status, extra = {}) { Object.assign(task, { status, ...extra }) persistMentions() setTimeout(() => setMentionChip(task, status), 0) } // --- mention polling (agents) --- export function handlesOwnedBy(username) { const agents = store.getAgents() return Object.keys(agents).filter(h => agents[h].owner === username) } function claimable(handles) { return Object.values(mentionState.tasks).filter(t => t.status === 'pending' && handles.includes(t.handle)) } async function claimAndEnrich(tasks) { const out = [] for (const task of tasks) { setTaskStatus(task, 'claimed', { claimedAt: Date.now(), attempts: (task.attempts || 0) + 1 }) let context = null try { context = await readDoc(task.docId, document => { const ythread = document.getMap('threads').get(task.threadId) const suggestionId = ythread?.get('suggestionId') || null const sugg = suggestionId ? document.getMap('suggestions').get(suggestionId) : null return { excerpt: ythread?.get('excerpt') || null, thread_messages: ythread?.get('messages')?.toArray() || [], suggestion: sugg ? { suggestion_id: sugg.id, author: sugg.author, status: sugg.status, original_markdown: sugg.originalMarkdown, replacement_markdown: sugg.replacementMarkdown, rationale: sugg.rationale, } : null, } }) } catch {} const registry = store.getRegistry() const projectId = projectIdOf(task.docId) const page = pageSlugOf(task.docId) out.push({ mention_id: task.id, doc_id: projectId, page, doc_title: registry[projectId]?.title || projectId, page_title: page === 'home' ? null : store.pagesOf(projectId)?.[page]?.title || page, thread_id: task.threadId, handle: task.handle, requested_by: task.requestedBy, instruction: task.text, anchored_text: context?.excerpt || null, thread_messages: (context?.thread_messages || []).map(m => ({ author: m.author, authorType: m.authorType, text: m.text })), about_suggestion: context?.suggestion || null, implicit_follow_up: !!task.implicit, created_at: task.ts, }) } return out } function pollableHandles(username, onlyHandle) { let handles = handlesOwnedBy(username) if (onlyHandle) { // agent-key poll: exactly this handle handles = handles.filter(h => h === onlyHandle) } else { // HF-token poll: only legacy handles without a dedicated key — once a key // exists, possession of the key IS the agent, and owner-level polls must // not steal its work const agents = store.getAgents() handles = handles.filter(h => !agents[h]?.keyHash) } return handles } const NO_HANDLES_ERROR = 'no agent handles registered for your account (handles with a key must poll with that key) — POST /api/agents first' // Instant, non-claiming view of outstanding work — for debugging. Receiving // (and claiming) work happens through the streaming poll. export function snapshotMentions(username, onlyHandle = null) { const handles = pollableHandles(username, onlyHandle) if (!handles.length) return { error: NO_HANDLES_ERROR } const mentions = Object.values(mentionState.tasks) .filter(t => handles.includes(t.handle) && (t.status === 'pending' || t.status === 'claimed')) .map(t => ({ mention_id: t.id, doc_id: t.docId, thread_id: t.threadId, handle: t.handle, status: t.status, instruction: t.text, created_at: t.ts })) return { snapshot: true, mentions } } export function pollMentions(username, waitSeconds, onlyHandle = null, opts = {}) { const handles = pollableHandles(username, onlyHandle) if (!handles.length) return Promise.resolve({ error: NO_HANDLES_ERROR }) for (const h of handles) agentLastPoll.set(h, Date.now()) const now = claimable(handles) if (now.length || !waitSeconds) return claimAndEnrich(now).then(mentions => ({ mentions })) const { maxWait = 55, registerCancel = null } = opts return new Promise(resolve => { const waiter = { handles: new Set(handles), done: false, fire: async tasks => { if (waiter.fired) return waiter.fired = true clearTimeout(waiter.timer) const idx = waiters.indexOf(waiter) if (idx !== -1) waiters.splice(idx, 1) for (const h of handles) agentLastPoll.set(h, Date.now()) resolve({ mentions: await claimAndEnrich(tasks) }) }, } waiter.timer = setTimeout(() => waiter.fire([]), Math.min(waitSeconds, maxWait) * 1000) waiters.push(waiter) // let the caller cancel when the client disconnects, so a parked waiter // can't claim mentions into a dead socket if (registerCancel) { registerCancel(() => { if (waiter.fired) return waiter.fired = true clearTimeout(waiter.timer) const idx = waiters.indexOf(waiter) if (idx !== -1) waiters.splice(idx, 1) resolve({ mentions: [], cancelled: true }) }) } }) } function wakeWaiters() { for (const waiter of [...waiters]) { if (waiter.fired) continue const tasks = claimable([...waiter.handles]) if (tasks.length) waiter.fire(tasks) } } export function agentPresence() { const agents = store.getAgents() return Object.entries(agents).map(([handle, info]) => { const hasWaiter = waiters.some(w => !w.fired && w.handles.has(handle)) const last = agentLastPoll.get(handle) || 0 return { handle, owner: info.owner, shared: info.shared !== false, online: hasWaiter || Date.now() - last < 70 * 1000, last_seen: last || null, } }) } export function dismissMention(mentionId, username) { const task = mentionState.tasks[mentionId] if (!task) return { error: 'mention not found' } const agents = store.getAgents() if (agents[task.handle]?.owner !== username) return { error: 'not your mention' } if (task.status !== 'pending' && task.status !== 'claimed') return { error: `mention already ${task.status}` } setTaskStatus(task, 'done', { doneAt: Date.now(), dismissed: true }) return { ok: true } } // When an agent handle is removed, fail its outstanding work so chips don't hang export function cancelAgentTasks(handle) { for (const task of Object.values(mentionState.tasks)) { if (task.handle === handle && (task.status === 'pending' || task.status === 'claimed')) { setTaskStatus(task, 'failed') } } } export function completeMention(mentionId, { docId, threadId, handle } = {}) { let completed = 0 for (const task of Object.values(mentionState.tasks)) { const explicit = mentionId && task.id === mentionId const implicit = !mentionId && docId && task.docId === docId && task.threadId === threadId && task.handle === handle if ((explicit || implicit) && (task.status === 'pending' || task.status === 'claimed')) { setTaskStatus(task, 'done', { doneAt: Date.now() }) completed++ } } return completed } // requeue stale claims, fail after MAX_ATTEMPTS setInterval(() => { let changed = false for (const task of Object.values(mentionState.tasks)) { if (task.status !== 'claimed' || Date.now() - (task.claimedAt || 0) < CLAIM_TIMEOUT_MS) continue changed = true if ((task.attempts || 0) >= MAX_ATTEMPTS) setTaskStatus(task, 'failed') else setTaskStatus(task, 'pending') } if (changed) wakeWaiters() }, 30 * 1000).unref() // --- doc reading for agents --- export async function getDocSnapshot(docName) { return readDoc(docName, document => { const pm = yDocToProsemirrorJSON(document, FIELD) const fragment = document.getXmlFragment(FIELD) const blocks = (pm.content || []).map((node, i) => ({ index: i, anchor_start: b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, i))), anchor_end: b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, i + 1))), markdown: blockToMarkdown(node) ?? '', })) const threads = [] document.getMap('threads').forEach((ythread, id) => { threads.push({ thread_id: id, excerpt: ythread.get('excerpt') || null, resolved: !!ythread.get('resolved'), messages: (ythread.get('messages')?.toArray() || []).map(m => ({ author: m.author, authorType: m.authorType, text: m.text })), }) }) const suggestions = [] document.getMap('suggestions').forEach(s => suggestions.push(s)) return { markdown: pmToMarkdown(pm), blocks, threads, suggestions: suggestions.map(s => ({ id: s.id, author: s.author, status: s.status, rationale: s.rationale })), } }) } // --- suggestions --- export async function createSuggestion(docName, { anchorStart, anchorEnd, blockIndex, replacementMarkdown, rationale, author, authorType, threadId, supersedes }) { return withDoc(docName, document => { // supersedes only LINKS a revision to the original (and reuses its anchors); // both stay open side by side — a human decides which, if any, to accept let revises = null if (supersedes) { const old = document.getMap('suggestions').get(supersedes) if (old && old.author === author) { revises = supersedes if (anchorStart == null && blockIndex == null) { anchorStart = old.anchorStart anchorEnd = old.anchorEnd } } } const fragment = document.getXmlFragment(FIELD) if (blockIndex != null && (anchorStart == null || anchorEnd == null)) { const i = Math.max(0, Math.min(Number(blockIndex), fragment.length - 1)) anchorStart = b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, i))) anchorEnd = b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, i + 1))) } const range = resolveBlockRange(document, fragment, anchorStart, anchorEnd) if (!range) return { error: 'anchor could not be resolved against the current document' } const pm = yDocToProsemirrorJSON(document, FIELD) const original = (pm.content || []).slice(range.from, range.to).map(blockToMarkdown).join('\n\n') const suggestion = { id: newId(10), anchorStart, anchorEnd, author, authorType, replacementMarkdown: String(replacementMarkdown ?? ''), rationale: rationale ? String(rationale) : null, originalMarkdown: original, status: 'open', revises, threadId: threadId || null, ts: Date.now(), } // re-anchor to the target block items themselves (see resolveBlockRange) suggestion.anchorStart = b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, range.from))) suggestion.anchorEndIncl = b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, Math.max(range.from, range.to - 1)))) // forward anchor: tracks the first stable block AFTER the range (or the // end-of-doc sentinel), so phantom insertion panels get pushed down by // content typed at the boundary const afterIdx = stableAfterIndex(pm, range.to) suggestion.anchorAfter = b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, afterIdx ?? fragment.length))) document.getMap('suggestions').set(suggestion.id, suggestion) return { suggestion } }) } // First content-bearing block at or after `from` — empty paragraphs are skipped // because y-prosemirror reuses them as shells when users type at boundaries, // which would drag an anchor onto the newly typed text. function stableAfterIndex(pm, from) { const content = pm.content || [] for (let i = from; i < content.length; i++) { const n = content[i] const empty = n.type === 'paragraph' && !(n.content || []).some(c => (c.text || '').trim()) if (!empty) return i } return null } // anchorEndIncl (when present) references the LAST target block itself, so // blocks inserted right after the suggestion stay outside the range. The // legacy exclusive anchorEnd referenced the NEXT block (or the end-of-doc // sentinel), which swallowed newly typed neighbours into the suggestion. function resolveBlockRange(document, fragment, anchorStart, anchorEnd, anchorEndIncl = null) { try { const absStart = Y.createAbsolutePositionFromRelativePosition(Y.decodeRelativePosition(b64decode(anchorStart)), document) const absEndRel = anchorEndIncl || anchorEnd const absEnd = Y.createAbsolutePositionFromRelativePosition(Y.decodeRelativePosition(b64decode(absEndRel)), document) if (!absStart || !absEnd || absStart.type !== fragment || absEnd.type !== fragment) return null const from = Math.max(0, Math.min(absStart.index, fragment.length)) const endIdx = anchorEndIncl ? absEnd.index + 1 : absEnd.index const to = Math.max(from, Math.min(Math.max(endIdx, from + 1), fragment.length)) return { from, to } } catch { return null } } const migratedAnchorDocs = new Set() function migrateLegacySuggestionAnchors(document) { if (document.isDestroyed) return const suggMap = document.getMap('suggestions') const fragment = document.getXmlFragment(FIELD) let pm = { content: [] } try { pm = yDocToProsemirrorJSON(document, FIELD) } catch {} const updates = [] suggMap.forEach((s, id) => { if (!s || (s.anchorEndIncl && s.anchorAfter) || (s.status !== 'open' && s.status !== 'superseded')) return const range = resolveBlockRange(document, fragment, s.anchorStart, s.anchorEnd, s.anchorEndIncl || null) if (!range) return updates.push([ id, { ...s, anchorStart: b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, range.from))), anchorEndIncl: b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, Math.max(range.from, range.to - 1)))), anchorAfter: b64encode( Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, stableAfterIndex(pm, range.to) ?? fragment.length)) ), }, ]) }) if (updates.length) { document.transact(() => { for (const [id, value] of updates) suggMap.set(id, value) }) } } export async function resolveSuggestionAction(docName, suggestionId, action, username, { markOnly = false } = {}) { return withDoc(docName, document => { const suggestions = document.getMap('suggestions') const suggestion = suggestions.get(suggestionId) if (!suggestion) return { error: 'suggestion not found' } if (action === 'reopen') { // undo of an accept: the text change was reverted client-side, bring the suggestion back if (suggestion.status !== 'accepted') return { error: `cannot reopen a ${suggestion.status} suggestion` } suggestions.set(suggestionId, { ...suggestion, status: 'open', resolvedBy: null, resolvedAt: null }) return { ok: true } } if (suggestion.status !== 'open' && suggestion.status !== 'superseded') return { error: `suggestion already ${suggestion.status}` } if (action === 'reject') { suggestions.set(suggestionId, { ...suggestion, status: 'rejected', resolvedBy: username, resolvedAt: Date.now() }) return { ok: true } } if (markOnly) { // the accepting client already applied the replacement locally (so it lands // in that user's undo history) — only record the status here suggestions.set(suggestionId, { ...suggestion, status: 'accepted', resolvedBy: username, resolvedAt: Date.now() }) return { ok: true } } const fragment = document.getXmlFragment(FIELD) const range = resolveBlockRange(document, fragment, suggestion.anchorStart, suggestion.anchorEnd, suggestion.anchorEndIncl || null) if (!range) { suggestions.set(suggestionId, { ...suggestion, status: 'orphaned' }) return { error: 'the anchored text no longer exists; suggestion marked orphaned' } } const nodes = markdownToBlocks(suggestion.replacementMarkdown) fragment.delete(range.from, range.to - range.from) fragment.insert(range.from, nodes.map(buildYBlock)) suggestions.set(suggestionId, { ...suggestion, status: 'accepted', resolvedBy: username, resolvedAt: Date.now() }) if (String(docName).endsWith('::_structure')) clearStructureCache(projectIdOf(docName)) return { ok: true } }) } // --- building Y nodes from markdown block descriptors --- function yInlineText(segments) { // Insert the full plain string first, then format() the marked ranges. // Inserting formatted segments sequentially would make later unformatted // segments inherit the previous segment's formatting (Yjs semantics). const t = new Y.XmlText() t.insert(0, (segments || []).map(s => s.text).join('')) let pos = 0 for (const seg of segments || []) { if (Object.keys(seg.attrs || {}).length) t.format(pos, seg.text.length, seg.attrs) pos += seg.text.length } return t } function paragraphOf(segments) { const p = new Y.XmlElement('paragraph') p.insert(0, [yInlineText(segments)]) return p } export function buildYBlock(block) { switch (block.type) { case 'heading': { const el = new Y.XmlElement('heading') el.setAttribute('level', block.attrs?.level || 1) el.insert(0, [yInlineText(block.inline)]) return el } case 'codeBlock': { const el = new Y.XmlElement('codeBlock') if (block.attrs?.language) el.setAttribute('language', block.attrs.language) const t = new Y.XmlText() t.insert(0, block.text || '') el.insert(0, [t]) return el } case 'bulletList': case 'orderedList': { const el = new Y.XmlElement(block.type) el.insert(0, (block.items || []).map(segments => { const li = new Y.XmlElement('listItem') li.insert(0, [paragraphOf(segments)]) return li })) return el } case 'taskList': { const el = new Y.XmlElement('taskList') el.insert(0, (block.items || []).map(it => { const li = new Y.XmlElement('taskItem') li.setAttribute('checked', !!it.checked) li.insert(0, [paragraphOf(it.inline)]) return li })) return el } case 'table': { const el = new Y.XmlElement('table') el.insert(0, (block.rows || []).map((cells, r) => { const row = new Y.XmlElement('tableRow') row.insert(0, cells.map(segs => { const cell = new Y.XmlElement(r === 0 ? 'tableHeader' : 'tableCell') cell.setAttribute('colspan', 1) cell.setAttribute('rowspan', 1) cell.insert(0, [paragraphOf(segs)]) return cell })) return row })) return el } case 'blockquote': { const el = new Y.XmlElement('blockquote') el.insert(0, [paragraphOf(block.inline)]) return el } case 'horizontalRule': return new Y.XmlElement('horizontalRule') case 'image': { const el = new Y.XmlElement('image') el.setAttribute('src', block.attrs?.src || '') if (block.attrs?.alt) el.setAttribute('alt', block.attrs.alt) return el } default: return paragraphOf(block.inline) } } export async function deleteDoc(id) { const pages = Object.keys(store.pagesOf(id) || { home: {} }) for (const slug of [...new Set(['home', '_structure', ...pages])]) { const name = docNameFor(id, slug) try { hocuspocus.closeConnections(name) } catch {} const un = docWatchers.get(name) if (un) { un() docWatchers.delete(name) } } for (const [taskId, task] of Object.entries(mentionState.tasks)) { if (projectIdOf(task.docId) === id) delete mentionState.tasks[taskId] } persistMentions() store.deleteDocFiles(id) } // --- doc creation --- export async function createDoc(title, username) { const id = newId(8) store.upsertRegistry(id, { title: title || 'Untitled', createdAt: Date.now(), updatedAt: Date.now(), createdBy: username, pages: {} }) await withDoc(id, document => { const fragment = document.getXmlFragment(FIELD) if (fragment.length === 0) { const h = new Y.XmlElement('heading') h.setAttribute('level', 1) const t = new Y.XmlText() t.insert(0, title || 'Untitled') h.insert(0, [t]) const p = new Y.XmlElement('paragraph') fragment.insert(0, [h, p]) } }) await ensureStructurePage(id) return id } // --- pages --- function structureCodeBlock(document) { const fragment = document.getXmlFragment(FIELD) for (let i = 0; i < fragment.length; i++) { const el = fragment.get(i) if (el instanceof Y.XmlElement && el.nodeName === 'codeBlock') return el } return null } const ensuredStructure = new Set() export async function ensureStructurePage(projectId) { if (ensuredStructure.has(projectId)) return const name = docNameFor(projectId, '_structure') if (store.loadDocState(name)) { ensuredStructure.add(projectId) return } const slugs = Object.keys(store.pagesOf(projectId) || { home: {} }) await withDoc(name, document => { const fragment = document.getXmlFragment(FIELD) if (fragment.length > 0) return // just the yaml field — the explanation lives in the page chrome const code = new Y.XmlElement('codeBlock') code.setAttribute('language', 'yaml') const ct = new Y.XmlText() ct.insert(0, defaultStructureYaml(slugs)) code.insert(0, [ct]) fragment.insert(0, [code]) }) store.upsertPageMeta(projectId, '_structure', { title: 'Structure', createdAt: Date.now() }) ensuredStructure.add(projectId) clearStructureCache(projectId) } export async function createPage(projectId, title, username, { slug: explicitSlug = null, contentMarkdown = null } = {}) { const pages = store.pagesOf(projectId) || {} let slug if (explicitSlug) { // creating a page that the structure yaml references but doesn't exist yet slug = String(explicitSlug) if (!PAGE_SLUG_RE.test(slug) || slug === '_structure' || slug === 'home') return { error: 'bad page slug' } if (pages[slug]) return { error: 'page already exists' } } else { const base = slugify(title) slug = base === '_structure' ? 'page' : base let n = 2 while (pages[slug] || slug === 'home') slug = `${base}-${n++}`.slice(0, 40) if (!PAGE_SLUG_RE.test(slug) || slug === '_structure') return { error: 'bad page title' } } const name = docNameFor(projectId, slug) await withDoc(name, document => { const fragment = document.getXmlFragment(FIELD) if (fragment.length > 0) return if (contentMarkdown != null && String(contentMarkdown).trim()) { // seed with the proposed content (accepting a new-page suggestion) fragment.insert(0, markdownToBlocks(contentMarkdown).map(buildYBlock)) } else { const h = new Y.XmlElement('heading') h.setAttribute('level', 1) const t = new Y.XmlText() t.insert(0, title || slug) h.insert(0, [t]) const p = new Y.XmlElement('paragraph') fragment.insert(0, [h, p]) } }) store.upsertPageMeta(projectId, slug, { title: title || slug, createdAt: Date.now(), createdBy: username }) await ensureStructurePage(projectId) // append to the structure yaml so the new page shows in the tree right away let newRaw = null await withDoc(docNameFor(projectId, '_structure'), document => { const code = structureCodeBlock(document) if (!code) return const text = code.get(0) if (!(text instanceof Y.XmlText)) return const current = text.toString() if (new RegExp(`^\\s*-\\s*${slug}\\s*$`, 'm').test(current)) return text.insert(current.length, `${current.endsWith('\n') || !current ? '' : '\n'}- ${slug}`) newRaw = text.toString() }) if (newRaw != null) primeStructureCache(projectId, newRaw) else clearStructureCache(projectId) return { slug } } export async function deletePage(projectId, slug) { if (slug === 'home' || slug === '_structure') return { error: 'this page cannot be deleted' } const name = docNameFor(projectId, slug) try { hocuspocus.closeConnections(name) } catch {} const un = docWatchers.get(name) if (un) { un() docWatchers.delete(name) } for (const [taskId, task] of Object.entries(mentionState.tasks)) { if (task.docId === name) delete mentionState.tasks[taskId] } persistMentions() store.deletePageFiles(name) store.removePageMeta(projectId, slug) // drop its line from the structure yaml let newRaw = null try { await withDoc(docNameFor(projectId, '_structure'), document => { const code = structureCodeBlock(document) const text = code?.get(0) if (!(text instanceof Y.XmlText)) return const lines = text.toString().split('\n') const keep = lines.filter(l => !new RegExp(`^\\s*-\\s*${slug}\\s*:?\\s*$`).test(l)) if (keep.length !== lines.length) { text.delete(0, text.length) text.insert(0, keep.join('\n')) } newRaw = text.toString() }) } catch {} if (newRaw != null) primeStructureCache(projectId, newRaw) else clearStructureCache(projectId) return { ok: true } } // One-time upgrade of legacy bullet lists whose every item is a checkbox // ("[ ] ..." / "[x] ...") — written before task-list support — into real task // lists. Only converts a list when ALL items match, so mixed lists are left // alone. Reuses the markdown round-trip (preserves inline formatting). function migrateCheckboxLists(document) { if (document.isDestroyed) return const fragment = document.getXmlFragment(FIELD) let pm try { pm = yDocToProsemirrorJSON(document, FIELD) } catch { return } const content = pm.content || [] const CHECKBOX = /^\[[ xX]?\]\s+\S/ const itemText = li => (li.content || []).map(b => (b.content || []).map(n => n.text || '').join('')).join(' ') const updates = [] content.forEach((node, i) => { if (node.type !== 'bulletList') return const items = node.content || [] if (items.length && items.every(li => CHECKBOX.test(itemText(li)))) { const blocks = markdownToBlocks(blockToMarkdown(node)) if (blocks.length === 1 && blocks[0].type === 'taskList') updates.push([i, blocks[0], itemText(items[0])]) } }) if (!updates.length) return document.transact(() => { // apply high index first so earlier indices stay valid; re-verify each // target is still the expected bullet list (guards against a concurrent // edit having shifted the doc between the snapshot and this transaction) for (const [i, block, firstText] of updates.reverse()) { const el = i < fragment.length ? fragment.get(i) : null if (!(el instanceof Y.XmlElement) || el.nodeName !== 'bulletList') continue if (!el.toString().replace(/<[^>]+>/g, '').trim().startsWith(firstText.slice(0, 12))) continue fragment.delete(i, 1) fragment.insert(i, [buildYBlock(block)]) } }) } // --- new-page proposals ------------------------------------------------------- // A proposal for a whole new page (title + content) lives in the project's // _structure doc, so it is project-level and visible before acceptance. On // accept it becomes a real page (with content) added to the structure. export async function createPageProposal(projectId, { title, contentMarkdown, rationale, author, authorType }) { const clean = String(title || '').trim().slice(0, 120) if (!clean) return { error: 'title required' } const pages = store.pagesOf(projectId) || {} await ensureStructurePage(projectId) const id = newId(10) let existingSlugs = new Set(Object.keys(pages)) const result = await withDoc(docNameFor(projectId, '_structure'), document => { const map = document.getMap('pageProposals') map.forEach(p => { if (p.status === 'open') existingSlugs.add(p.slug) }) const base = slugify(clean) let slug = base === '_structure' ? 'page' : base let n = 2 while (existingSlugs.has(slug) || slug === 'home') slug = `${base}-${n++}`.slice(0, 40) map.set(id, { id, kind: 'newPage', slug, title: clean, contentMarkdown: String(contentMarkdown || ''), rationale: rationale ? String(rationale) : null, author, authorType, status: 'open', ts: Date.now(), }) return { slug } }) clearStructureCache(projectId) return { id, slug: result.slug } } export async function getPageProposal(projectId, pid) { return readDoc(docNameFor(projectId, '_structure'), document => { const p = document.getMap('pageProposals').get(pid) return p || null }) } export async function resolvePageProposal(projectId, pid, action, username) { const proposal = await getPageProposal(projectId, pid) if (!proposal) return { error: 'proposal not found' } if (proposal.status !== 'open') return { error: `proposal already ${proposal.status}` } if (action === 'reject') { await withDoc(docNameFor(projectId, '_structure'), document => { const map = document.getMap('pageProposals') const p = map.get(pid) if (p) map.set(pid, { ...p, status: 'rejected', resolvedBy: username, resolvedAt: Date.now() }) }) clearStructureCache(projectId) return { ok: true } } // accept: create the page with its content, add to structure, mark accepted const created = await createPage(projectId, proposal.title, username, { slug: proposal.slug, contentMarkdown: proposal.contentMarkdown, }) if (created.error) return created await withDoc(docNameFor(projectId, '_structure'), document => { const map = document.getMap('pageProposals') const p = map.get(pid) if (p) map.set(pid, { ...p, status: 'accepted', resolvedBy: username, resolvedAt: Date.now(), slug: created.slug }) }) clearStructureCache(projectId) return { ok: true, slug: created.slug } }