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 , SHARE_COOKIE } from './auth.js' import { DOC_NAME_RE, projectIdOf, pageSlugOf, docNameFor, slugify, PAGE_SLUG_RE, parseCookies } from './util.js' import { defaultStructureYaml, clearStructureCache, primeStructureCache, moveNodesInYaml } from './pages.js' const FIELD = 'default' const UNIFIED_PAGES_VERSION = 1 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 // Keyed by the document INSTANCE, not by name: two instances of the same // project must never be able to unhook each other's observer (see attachWatcher). const docWatchers = new Map() // document -> { un } function pageField(docName) { const slug = pageSlugOf(docName) return slug === 'home' ? FIELD : `page:${slug}` } function threadsMap(document, docName) { const slug = pageSlugOf(docName) return document.getMap(slug === 'home' ? 'threads' : `threads:${slug}`) } function suggestionsMap(document, docName) { const slug = pageSlugOf(docName) return document.getMap(slug === 'home' ? 'suggestions' : `suggestions:${slug}`) } function projectPageNames(projectId) { return [...new Set(['home', '_structure', ...Object.keys(store.pagesOf(projectId) || {})])].map(slug => docNameFor(projectId, slug)) } function cloneYValue(value) { if (value instanceof Y.XmlText) { const out = new Y.XmlText() out.applyDelta(value.toDelta()) return out } if (value instanceof Y.XmlElement) { const out = new Y.XmlElement(value.nodeName) for (const [key, val] of Object.entries(value.getAttributes())) out.setAttribute(key, val) if (value.length) out.insert(0, value.toArray().map(cloneYValue)) return out } if (value instanceof Y.Array) { const out = new Y.Array() if (value.length) out.insert(0, value.toArray().map(cloneYValue)) return out } if (value instanceof Y.Map) { const out = new Y.Map() value.forEach((val, key) => out.set(key, cloneYValue(val))) return out } return value == null || typeof value !== 'object' ? value : structuredClone(value) } function importLegacyPages(projectId, document) { const projectMeta = document.getMap('projectMeta') if ((projectMeta.get('unifiedPagesVersion') || 0) >= UNIFIED_PAGES_VERSION) return document.transact(() => { for (const name of projectPageNames(projectId)) { const slug = pageSlugOf(name) if (slug === 'home') continue const state = store.loadLegacyPageState(projectId, slug) if (!state) continue const legacy = new Y.Doc() try { Y.applyUpdate(legacy, state) const source = legacy.getXmlFragment(FIELD) const target = document.getXmlFragment(pageField(name)) if (!target.length && source.length) target.insert(0, source.toArray().map(cloneYValue)) for (const base of ['threads', 'suggestions']) { const sourceMap = legacy.getMap(base) const targetMap = base === 'threads' ? threadsMap(document, name) : suggestionsMap(document, name) if (!targetMap.size) sourceMap.forEach((value, key) => targetMap.set(key, cloneYValue(value))) } if (slug === '_structure') { const sourceMap = legacy.getMap('pageProposals') const targetMap = document.getMap('pageProposals') sourceMap.forEach((value, key) => { if (!targetMap.has(key)) targetMap.set(key, cloneYValue(value)) }) } } finally { legacy.destroy() } } projectMeta.set('unifiedPagesVersion', UNIFIED_PAGES_VERSION) }) } function persistMentions() { store.saveMentionState(mentionState) } // --- hocuspocus server --- async function authenticate({ requestHeaders, token, documentName, connectionConfig }) { if (documentName && !DOC_NAME_RE.test(documentName)) throw new Error('bad document name') // Pages stopped being their own documents when they became fields of the // project document. A page-scoped name would sync a second, isolated replica // of the same project: edits made in it reach nobody. Fail loudly instead. if (documentName && documentName !== projectIdOf(documentName)) { throw new Error(`sync the project document ("${projectIdOf(documentName)}"), not a page — pages are fields of it`) } 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) } } const cookie = requestHeaders?.cookie || requestHeaders?.get?.('cookie') || '' if (!user) user = sessionFromCookieHeader(cookie) const access = user && documentName ? store.canAccessDoc(documentName, user.username) : false // A public link opens this ONE project, and only to read it. This is checked // for signed-out AND signed-in visitors: being logged in to your own account // is not a reason for someone else's link to stop working, and the REST side // already lets that request through — the two must not disagree, or the page // loads and then never syncs. // // readOnly is set on the connection itself, so the server drops any update or // awareness message the client sends: the editor being non-editable in the UI // is a courtesy, this is the rule. if (!access && documentName) { const shareToken = parseCookies(cookie)[SHARE_COOKIE] const sharedDoc = shareToken ? store.docByShareToken(shareToken) : null if (sharedDoc && projectIdOf(documentName) === sharedDoc) { if (connectionConfig) connectionConfig.readOnly = true return { user: { username: user?.username || null, name: user?.name || 'Viewer', avatar: user?.avatar || null, viewer: true } } } } if (!user) throw new Error('unauthorized') if (documentName && !access) 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 }) { // A project's pages are fields of ONE document, so its state is stored under // the project id. Loading by the raw name would resurrect the pre-migration // per-page row — an instance frozen at whatever that page looked like before // the migration, missing every page added since. const state = store.loadDocState(projectIdOf(documentName)) if (state) Y.applyUpdate(document, state) importLegacyPages(projectIdOf(documentName), document) return document }, async afterLoadDocument({ documentName, document }) { const projectId = projectIdOf(documentName) attachWatcher(projectId, document) // catch up on anything added while the server was down setTimeout(() => scanProjectForMentions(projectId, 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(projectId)) { migratedAnchorDocs.add(projectId) setTimeout(() => { for (const name of projectPageNames(projectId)) { try { migrateLegacySuggestionAnchors(document, name) } catch {} try { migrateCheckboxLists(document, name) } catch {} // structure pages are just the yaml field now — drop old heading/intro try { if (pageSlugOf(name) === '_structure' && !document.isDestroyed) { const fragment = document.getXmlFragment(pageField(name)) 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 }) { releaseWatcher(documentName, { onlyIfDestroyed: true }) }, async onStoreDocument({ documentName, document }) { const projectId = projectIdOf(documentName) // Only the canonical instance owns the project's row. A replica loaded under // a page-scoped name holds a partial copy — letting it write would drop // whatever the real instance has that the replica never saw. if (documentName !== projectId) return let homeMarkdown = null for (const name of projectPageNames(projectId)) { try { const slug = pageSlugOf(name) const pm = yDocToProsemirrorJSON(document, pageField(name)) if (slug === 'home') homeMarkdown = pmToMarkdown(pm) // the subtitle only describes the project, so only home has one store.touchProjectOnStore(projectId, slug, titleFromPm(pm), slug === 'home' ? subtitleFromPm(pm) : null) } catch {} } store.saveDocState(projectId, Y.encodeStateAsUpdate(document), homeMarkdown) }, }) // The plain text of a ProseMirror block, with the markup that markdown adds // around it taken back off — what a title or a subtitle reads as, not how it is // written. (blockPlainText below is the same idea for *parsed markdown* blocks.) function pmBlockText(node) { return ( blockToMarkdown(node) ?.replace(/^#+\s*/, '') .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images -> their alt text, if any .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links -> their text .replace(/[*_`]/g, '') .trim() || '' ) } function titleFromPm(pm) { for (const node of pm.content || []) { const text = pmBlockText(node) if (text) return text.slice(0, 80) } return null } // The project's one-line description: the first ordinary paragraph under the // title, the way the page itself opens. Derived like the title — read-only, on // store, never written back into the document. Section headings, code, lists and // tables are not prose, so they are passed over rather than ending the search. const SUBTITLE_MAX = 160 function subtitleFromPm(pm) { let pastTitle = false for (const node of pm.content || []) { const text = pmBlockText(node) if (!text) continue if (!pastTitle) { pastTitle = true // whatever it was, that block is the title continue } if (node.type !== 'paragraph') continue return text.slice(0, SUBTITLE_MAX).trim() } return null } // --- document watcher: detect new @mentions in comment threads --- // Watch the LIVE document instance, not just the name. A document can be // unloaded and re-loaded (every direct connection that disconnects can trigger // it), and afterUnloadDocument for the old instance may fire *after* // afterLoadDocument for the new one. Keying only by name meant the new instance // could end up with no observer at all — and then @mentions in that document // were silently never detected for the rest of the process's life. // // Hence the key is the instance itself. Keying by *project id* had the same // silent-death failure through another door: a client connecting under a // page-scoped name (`::`) loads a SECOND instance of the same // project, and attaching its watcher took the observer off the instance every // browser is actually editing. One such connection and the project stopped // noticing @mentions until the process restarted. function attachWatcher(docName, document) { if (docWatchers.has(document)) return const projectId = projectIdOf(docName) const observer = transaction => { const changedPages = new Set() for (const [name, type] of document.share.entries()) { if (!transaction.changedParentTypes.has(type)) continue if (name === 'threads') changedPages.add('home') else if (name.startsWith('threads:')) changedPages.add(name.slice('threads:'.length)) } for (const slug of changedPages) { setTimeout(() => scanDocForMentions(docNameFor(projectId, slug), document), 0) } } document.on('afterTransaction', observer) docWatchers.set(document, { projectId, un: () => document.off('afterTransaction', observer) }) } // Drop this project's watchers. afterUnloadDocument only tells us the name, and // an unloaded instance is always destroy()ed first — so with onlyIfDestroyed a // live replacement (or a same-project sibling instance) keeps its observer. function releaseWatcher(docName, { onlyIfDestroyed = false } = {}) { const projectId = projectIdOf(docName) for (const [document, entry] of docWatchers) { if (entry.projectId !== projectId) continue if (onlyIfDestroyed && !document.isDestroyed) continue try { entry.un() } catch {} docWatchers.delete(document) } } function scanProjectForMentions(projectId, document) { for (const docName of projectPageNames(projectId)) scanDocForMentions(docName, document) } function scanDocForMentions(docName, document) { if (document.isDestroyed) return const threads = threadsMap(document, docName) 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 an // UNSHARED agent only answers mentions written by its owner. Sharing is // opt-in: a handle with no flag stored has never been shared. const usable = h => store.canAccessDoc(docName, agents[h]?.owner) && (agents[h]?.shared === true || 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 = suggestionsMap(document, docName).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(() => writeChips(docName, threadId, msg.id, chips), 0) } }) }) if (created.length) { persistMentions() wakeWaiters() } } // The chip is the author's only sign that the mention was picked up, and the // message is marked processed before it is written — so a write that loses the // race with the client's own thread transaction must be retried, not dropped. async function writeChips(docName, threadId, messageId, chips, attempt = 0) { const ok = await updateMessage(docName, threadId, messageId, { mentions: chips }).catch(() => false) if (ok || attempt >= 5) return setTimeout(() => writeChips(docName, threadId, messageId, chips, attempt + 1), 150 * (attempt + 1)) } // --- privileged Y.Doc writes --- // Server-side writes/reads open a direct connection. Disconnecting one defaults // to unloading the document *immediately* — and since every API call does this, // it kept racing with browsers connecting to the same document: hocuspocus could // drop the instance a client had just been attached to, leaving that tab talking // to a Y.Doc the server no longer knows about. From then on server writes (agent // replies, suggestions, mention chips) landed in a fresh instance and never // reached the tab. `unloadImmediately: false` defers the unload to the store // debounce, which re-checks the connection count, so an arriving client wins. const KEEP_LOADED = { unloadImmediately: false } async function withDoc(docName, fn) { const conn = await hocuspocus.openDirectConnection(projectIdOf(docName), { user: { username: '__server__' } }) try { let result await conn.transact(document => { result = fn(document) }) return result } finally { await conn.disconnect(KEEP_LOADED) } } async function readDoc(docName, fn) { const conn = await hocuspocus.openDirectConnection(projectIdOf(docName), { user: { username: '__server__' } }) try { return fn(conn.document) } finally { await conn.disconnect(KEEP_LOADED) } } export async function updateMessage(docName, threadId, messageId, patch) { return withDoc(docName, document => { const ythread = threadsMap(document, docName).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 = threadsMap(document, docName).get(threadId) const messages = ythread?.get('messages') if (!messages) return false messages.push([msg]) return true }) return ok ? msg : null } // Open a new comment thread. Anchored (block_index, or an anchor pair from GET // /api/docs/:id blocks) marks the text it is about; with neither it is a // page-level comment — no highlight, its card stacks after the anchored ones. // Browsers write threads straight into the Yjs map; this is the same shape, so // an agent can leave several small comments instead of one long reply. export async function createThread(docName, { anchorStart, anchorEnd, blockIndex, excerpt, text, author, authorType, originThreadId }) { return withDoc(docName, document => { if (originThreadId && !threadsMap(document, docName).has(originThreadId)) { return { error: 'origin_thread_id does not name a comment on this page' } } const fragment = document.getXmlFragment(pageField(docName)) if (blockIndex != null && (anchorStart == null || anchorEnd == null)) { if (!fragment.length) return { error: 'this page has no blocks yet — post it without an anchor' } 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))) } let anchored = false if (anchorStart != null && anchorEnd != null) { const range = resolveBlockRange(document, fragment, anchorStart, anchorEnd) if (!range) return { error: 'anchor could not be resolved against the current document' } anchored = true if (!excerpt) { // the card shows the text it is about, exactly as a browser-made comment does const pm = yDocToProsemirrorJSON(document, pageField(docName)) const target = (pm.content || []).slice(range.from, range.to).map(pmBlockText).filter(Boolean).join(' ') excerpt = target.slice(0, 140) || null } } const message = { id: newId(10), author, authorType, text: String(text).slice(0, 8000), ts: Date.now() } const messages = new Y.Array() messages.push([message]) const id = newId(10) const ythread = new Y.Map() ythread.set('id', id) if (anchored) { ythread.set('anchorStart', anchorStart) ythread.set('anchorEnd', anchorEnd) } ythread.set('excerpt', excerpt || null) ythread.set('createdBy', author) ythread.set('createdAt', Date.now()) ythread.set('resolved', false) if (originThreadId) ythread.set('originThreadId', originThreadId) ythread.set('messages', messages) threadsMap(document, docName).set(id, ythread) return { threadId: id, messageId: message.id, anchored } }) } export async function setMentionChip(task, status) { try { const ythreadOk = await readDoc(task.docId, document => { const ythread = threadsMap(document, task.docId).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 = threadsMap(document, task.docId).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 = threadsMap(document, task.docId).get(task.threadId) const suggestionId = ythread?.get('suggestionId') || null const sugg = suggestionId ? suggestionsMap(document, task.docId).get(suggestionId) : null return { excerpt: ythread?.get('excerpt') || null, anchor_kind: ythread?.get('anchorKind') === 'position' ? 'position' : 'span', anchor_context: ythread?.get('anchorContext') || null, origin_thread_id: ythread?.get('originThreadId') || sugg?.originThreadId || sugg?.threadId || 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, // A request in a derived comment keeps pointing at the root request, so // any further comments/suggestions join the same visual family. origin_thread_id: context?.origin_thread_id || task.threadId, handle: task.handle, requested_by: task.requestedBy, instruction: task.text, // A position-anchored comment has no attached span, so anchored_text // carries the paragraph it was left beside; anchor_kind says which of the // two an agent is reading, so it does not answer as though the words were // the subject. anchored_text: context?.excerpt || context?.anchor_context || null, anchor_kind: context?.anchor_kind || 'span', 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, { includeManaged = false } = {}) { 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) } if (!includeManaged) { const agents = store.getAgents() handles = handles.filter(h => !agents[h]?.managed) } 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, opts) 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) } } // Revoking a link has to reach the sockets already open on it. A viewer whose // connection was authenticated before the token changed stays attached and keeps // receiving edits otherwise — "revoked" would mean "no new visitors", which is // not what the button says. export function closeShareViewers(projectId) { const document = hocuspocus.documents.get(projectId) if (!document) return 0 let closed = 0 for (const connection of document.getConnections()) { // readOnly + viewer is only ever a share-link connection in this app if (!connection.readOnly || !connection.context?.user?.viewer) continue connection.close({ reason: 'this share link was revoked' }) closed++ } return closed } 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 === true, managed: info.managed || null, model: info.model || null, 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, pageField(docName)) const fragment = document.getXmlFragment(pageField(docName)) 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 = [] threadsMap(document, docName).forEach((ythread, id) => { threads.push({ thread_id: id, origin_thread_id: ythread.get('originThreadId') || null, excerpt: ythread.get('excerpt') || null, // A position-anchored comment sits BESIDE a paragraph and quotes nothing, // so excerpt is null and anchor_context carries the paragraph's opening // words instead. anchor_kind is what tells an agent which it is looking // at — without it, a null excerpt is indistinguishable from a comment // whose text was deleted. anchor_kind: ythread.get('anchorKind') === 'position' ? 'position' : 'span', anchor_context: ythread.get('anchorContext') || null, resolved: !!ythread.get('resolved'), messages: (ythread.get('messages')?.toArray() || []).map(m => ({ author: m.author, authorType: m.authorType, text: m.text })), }) }) const suggestions = [] suggestionsMap(document, docName).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, origin_thread_id: s.originThreadId || s.threadId || null, })), } }) } // --- suggestions --- export async function createSuggestion(docName, { anchorStart, anchorEnd, blockIndex, replacementMarkdown, rationale, author, authorType, threadId, originThreadId, supersedes }) { return withDoc(docName, document => { if (originThreadId && !threadsMap(document, docName).has(originThreadId)) { return { error: 'origin_thread_id does not name a comment on this page' } } // 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 let inheritedOrigin = null if (supersedes) { const old = suggestionsMap(document, docName).get(supersedes) if (old && old.author === author) { revises = supersedes inheritedOrigin = old.originThreadId || old.threadId || null if (anchorStart == null && blockIndex == null) { anchorStart = old.anchorStart anchorEnd = old.anchorEnd } } } const fragment = document.getXmlFragment(pageField(docName)) 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' } // A comment thread represents one requested edit. If its agent posts a // fuller draft against the same target, update the existing proposal // instead of leaving two independently acceptable copies behind. Keep // unthreaded proposals independent: the playground and human review flow // deliberately support competing alternatives there. let existing = null if (threadId) { suggestionsMap(document, docName).forEach(s => { if (existing || s.author !== author || s.threadId !== threadId || !['open', 'superseded'].includes(s.status)) return const other = resolveBlockRange(document, fragment, s.anchorStart, s.anchorEnd, s.anchorEndIncl || null) if (other && range.from < other.to && other.from < range.to) existing = s }) } if (existing) { const updated = { ...existing, replacementMarkdown: String(replacementMarkdown ?? ''), rationale: rationale ? String(rationale) : null, revises: revises || existing.revises || null, updatedAt: Date.now(), } suggestionsMap(document, docName).set(existing.id, updated) return { suggestion: updated, updated: true } } const pm = yDocToProsemirrorJSON(document, pageField(docName)) 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, originThreadId: originThreadId || inheritedOrigin || 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))) // At end-of-document, later appends are legitimate neighbours rather than // evidence that the target expanded, so there is no stable gap to enforce. suggestion.anchorAfterGap = afterIdx == null ? null : afterIdx - range.to suggestionsMap(document, docName).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 } } function resolveAnchorIndex(document, fragment, encoded) { try { const abs = Y.createAbsolutePositionFromRelativePosition(Y.decodeRelativePosition(b64decode(encoded)), document) return abs && abs.type === fragment ? abs.index : null } catch { return null } } const normalizedMarkdown = value => String(value || '').replace(/\r\n/g, '\n').trim() // Suggestions remember both the target markdown and how far away the next // stable block was. The latter catches the real duplicate-producing case: an // accepted proposal expands one block into several while a queued proposal's // item anchor remains attached to only the first replacement block. function currentSuggestionRange(document, fragment, suggestion, field = FIELD) { const range = resolveBlockRange(document, fragment, suggestion.anchorStart, suggestion.anchorEnd, suggestion.anchorEndIncl || null) if (!range) return null const pm = yDocToProsemirrorJSON(document, field) const current = (pm.content || []).slice(range.from, range.to).map(blockToMarkdown).join('\n\n') if (normalizedMarkdown(current) !== normalizedMarkdown(suggestion.originalMarkdown)) return null if (Number.isInteger(suggestion.anchorAfterGap) && suggestion.anchorAfter) { const after = resolveAnchorIndex(document, fragment, suggestion.anchorAfter) if (after == null || after - range.to !== suggestion.anchorAfterGap) return null } return range } const migratedAnchorDocs = new Set() function migrateLegacySuggestionAnchors(document, docName) { if (document.isDestroyed) return const suggMap = suggestionsMap(document, docName) const field = pageField(docName) 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) }) } } // Did the accepting client's local apply really reach this document? Two // independent checks, because getting this wrong in either direction is bad: // trusting a dead client loses the content, doubting a live one duplicates it. // 1. presence — a synced editor publishes awareness (CollaborationCaret); // a tab with a rejected/closed socket has none. // 2. content — the anchored blocks already read as the replacement. // Either one is enough to conclude the change is in the document. function clientApplied(document, suggestion, username, field = FIELD, page = 'home') { try { for (const st of document.awareness.getStates().values()) { if (st?.page === page && st?.user?.name === username) return true } } catch {} try { // Anchor-independent on purpose: a client that applied the replacement has // already moved the anchor, so compare against the whole page instead. Each // replacement block must appear in full — matching a prefix would mistake a // shrinking edit (same opening, sentence removed) for one already applied. const pm = yDocToProsemirrorJSON(document, field) const flat = node => (node.text || '') + (node.content || []).map(flat).join(node.type === 'paragraph' ? '' : ' ') const docText = norm((pm.content || []).map(flat).join(' ')) const blocks = markdownToBlocks(suggestion.replacementMarkdown) if (!blocks.length) return false return blocks.every(b => { const want = norm(blockPlainText(b)) return !want || docText.includes(want) }) } catch { return false } } const norm = s => String(s || '').replace(/\s+/g, ' ').trim() // reader-visible text of a parsed block (marks and structure dropped) function blockPlainText(b) { const seg = ss => (ss || []).map(x => x.text || '').join('') if (b.type === 'bulletList' || b.type === 'orderedList') return (b.items || []).map(seg).join(' ') if (b.type === 'taskList') return (b.items || []).map(it => seg(it.inline)).join(' ') if (b.type === 'table') return (b.rows || []).map(r => r.map(seg).join(' ')).join(' ') if (b.type === 'codeBlock' || b.type === 'htmlBlock') return b.text || '' if (b.type === 'image' || b.type === 'horizontalRule') return '' return seg(b.inline) } export async function resolveSuggestionAction(docName, suggestionId, action, username, { markOnly = false, validateOnly = false } = {}) { return withDoc(docName, document => { const suggestions = suggestionsMap(document, docName) 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 } } const field = pageField(docName) const fragment = document.getXmlFragment(field) if (validateOnly) { if (!currentSuggestionRange(document, fragment, suggestion, field)) { return { error: 'the addressed text changed after this suggestion was created; revise the suggestion before accepting it' } } return { ok: true } } // mark_only means "I already applied the replacement in my editor" (so it // lands in that user's undo history). It is only true if that client's // websocket actually delivered the change — a tab whose socket is dead // (rejected as outdated, offline) still reaches this endpoint over HTTP, and // trusting it there recorded accepts whose content was silently dropped. if (markOnly && clientApplied(document, suggestion, username, field, pageSlugOf(docName))) { suggestions.set(suggestionId, { ...suggestion, status: 'accepted', resolvedBy: username, resolvedAt: Date.now() }) return { ok: true } } const range = currentSuggestionRange(document, fragment, suggestion, field) if (!range) { return { error: 'the addressed text changed after this suggestion was created; revise the suggestion before accepting it' } } 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 } // Inline content is usually one XmlText run, but a formula is an element, so a // paragraph containing math becomes an alternating list of text runs and math // elements. Each text run is built on its own — formatting must not leak across // a formula, and the offsets above are per-run. function yInlineChildren(segments) { const out = [] let run = [] const flush = () => { if (run.length) out.push(yInlineText(run)) run = [] } for (const seg of segments || []) { if (seg.math != null) { flush() const m = new Y.XmlElement('mathInline') m.setAttribute('latex', seg.math) out.push(m) } else if (seg.text) { run.push(seg) } } flush() // an empty paragraph still needs its text node, or the editor sees no content return out.length ? out : [yInlineText([])] } function paragraphOf(segments) { const p = new Y.XmlElement('paragraph') p.insert(0, yInlineChildren(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, yInlineChildren(block.inline)) return el } case 'mathBlock': { const el = new Y.XmlElement('mathBlock') el.setAttribute('latex', block.attrs?.latex || '') 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 'htmlBlock': { const el = new Y.XmlElement('htmlBlock') el.setAttribute('html', block.text || '') return el } 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) { try { hocuspocus.closeConnections(id) } catch {} releaseWatcher(id) 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, projectId) { const fragment = document.getXmlFragment(pageField(docNameFor(projectId, '_structure'))) 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') const slugs = Object.keys(store.pagesOf(projectId) || { home: {} }) await withDoc(name, document => { const fragment = document.getXmlFragment(pageField(name)) 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) } // Apply a pure text mutation ({raw}|{error}) to the structure YAML inside the // Yjs doc. The write is a minimal splice (common prefix/suffix kept) so a // human concurrently editing another part of the yaml merges sanely instead // of colliding with a full-text replace. export async function editStructureYaml(projectId, mutate) { await ensureStructurePage(projectId) let result = null await withDoc(docNameFor(projectId, '_structure'), document => { const code = structureCodeBlock(document, projectId) const text = code?.get(0) if (!(text instanceof Y.XmlText)) { result = { error: 'structure yaml not found' } return } const current = text.toString() const r = mutate(current) if (r.error) { result = r return } if (r.raw !== current) { const next = r.raw let p = 0 while (p < current.length && p < next.length && current[p] === next[p]) p++ let endCur = current.length let endNext = next.length while (endCur > p && endNext > p && current[endCur - 1] === next[endNext - 1]) { endCur-- endNext-- } if (endCur > p) text.delete(p, endCur - p) if (endNext > p) text.insert(p, next.slice(p, endNext)) } result = { ok: true, raw: r.raw } }) if (result?.raw != null) primeStructureCache(projectId, result.raw) else clearStructureCache(projectId) return result } export async function createPage(projectId, title, username, { slug: explicitSlug = null, contentMarkdown = null } = {}) { title = String(title || '') 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 = title.trim() ? slugify(title) : 'untitled' 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(pageField(name)) 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) if (title.trim()) { const t = new Y.XmlText() t.insert(0, title) h.insert(0, [t]) } const p = new Y.XmlElement('paragraph') fragment.insert(0, [h, p]) } }) store.upsertPageMeta(projectId, slug, { title: title.trim() || 'Untitled', 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, projectId) 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 } } // Rename by rewriting the page's first heading (creating one if the page // starts without any) — the H1 IS the title, so the document, the tab, and // the sidebar stay in sync. Renaming home renames the project. export async function renamePage(projectId, slug, title) { const clean = String(title || '').trim().slice(0, 120) if (!clean) return { error: 'title required' } if (slug === '_structure') return { error: 'this page cannot be renamed' } if (slug !== 'home' && !store.pagesOf(projectId)?.[slug]) return { error: 'no such page' } const name = docNameFor(projectId, slug) await withDoc(name, document => { const fragment = document.getXmlFragment(pageField(name)) // the title is derived from the first textblock with text (titleFromPm), // so rewrite exactly that block when it's a heading — else prepend an H1 const hasText = el => { for (let i = 0; i < el.length; i++) { const child = el.get(i) if (child instanceof Y.XmlText && child.length) return true if (child instanceof Y.XmlElement && hasText(child)) return true } return false } let heading = null for (let i = 0; i < fragment.length; i++) { const el = fragment.get(i) if (!(el instanceof Y.XmlElement) || !hasText(el)) continue if (el.nodeName === 'heading') heading = el break } if (!heading && fragment.length && fragment.get(0) instanceof Y.XmlElement && fragment.get(0).nodeName === 'heading' && !hasText(fragment.get(0))) { // brand-new untitled page: an empty H1 sits at the top — fill it heading = fragment.get(0) } if (heading) { if (heading.length) heading.delete(0, heading.length) const t = new Y.XmlText() t.insert(0, clean) heading.insert(0, [t]) } else { const h = new Y.XmlElement('heading') h.setAttribute('level', 1) const t = new Y.XmlText() t.insert(0, clean) h.insert(0, [t]) fragment.insert(0, [h]) } }) store.touchProjectOnStore(projectId, slug, clean) clearStructureCache(projectId) return { ok: true } } export async function deletePage(projectId, slug) { if (slug === 'home' || slug === '_structure') return { error: 'this page cannot be deleted' } const name = docNameFor(projectId, slug) for (const [taskId, task] of Object.entries(mentionState.tasks)) { if (task.docId === name) delete mentionState.tasks[taskId] } persistMentions() await withDoc(name, document => { const fragment = document.getXmlFragment(pageField(name)) if (fragment.length) fragment.delete(0, fragment.length) threadsMap(document, name).clear() suggestionsMap(document, name).clear() }) 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, projectId) 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, docName) { if (document.isDestroyed) return const field = pageField(docName) 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, parent = null, index = null }) { 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, // where in the tree it is proposed to live: same {parent, index} refs the // move endpoint uses (null parent = top level). Carried on the proposal so // the sidebar can show it in place, before anyone accepts it. parent: parent ?? null, index: Number.isInteger(index) ? index : 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 // createPage appends at the top level; a proposal that asked for a spot in the // hierarchy gets moved there, in the same edit vocabulary as a drag would use. // A parent that has since been dissolved or deleted is not an error — the page // is already created, so it just stays where it landed. if (proposal.parent != null || proposal.index != null) { const placed = await editStructureYaml(projectId, raw => moveNodesInYaml(raw, [created.slug], proposal.parent ?? null, proposal.index ?? undefined)) if (placed?.error) console.log(`[pageProposal] ${pid} accepted but not placed under ${JSON.stringify(proposal.parent)}: ${placed.error}`) } 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 } }