Spaces:
Sleeping
Sleeping
| 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:<id>") — 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 (`<id>::<slug>`) 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 } | |
| } | |