| import { Hocuspocus } from '@hocuspocus/server' |
| import * as Y from 'yjs' |
| import { yDocToProsemirrorJSON } from 'y-prosemirror' |
| import { newId, b64encode, b64decode } from './util.js' |
| import * as store from './store.js' |
| import { pmToMarkdown, blockToMarkdown, markdownToBlocks } from './md.js' |
| import { sessionFromCookieHeader, userFromToken, BUILD_ID } from './auth.js' |
| import { DOC_NAME_RE, projectIdOf, pageSlugOf, docNameFor, slugify, PAGE_SLUG_RE } from './util.js' |
| import { defaultStructureYaml, clearStructureCache, primeStructureCache } from './pages.js' |
|
|
| const FIELD = 'default' |
| const CLAIM_TIMEOUT_MS = 5 * 60 * 1000 |
| const MAX_ATTEMPTS = 3 |
|
|
| |
| const mentionState = store.getMentionState() |
| const waiters = [] |
| const agentLastPoll = new Map() |
| const docWatchers = new Map() |
|
|
| function persistMentions() { |
| store.saveMentionState(mentionState) |
| } |
|
|
| |
|
|
| async function authenticate({ requestHeaders, token, documentName }) { |
| if (documentName && !DOC_NAME_RE.test(documentName)) throw new Error('bad document name') |
| let user = null |
| if (token && token.startsWith('cookie')) { |
| |
| |
| |
| const clientBuild = token.split(':')[1] || null |
| if (BUILD_ID !== 'dev' && clientBuild !== BUILD_ID) { |
| throw new Error('outdated client — reload the page to get the current version') |
| } |
| } else if (token) { |
| if (token.startsWith('ak_')) { |
| const agent = store.agentByKey(token) |
| if (agent) user = { username: agent.owner, name: agent.owner, avatar: null } |
| } else { |
| user = await userFromToken(token) |
| } |
| } |
| if (!user) user = sessionFromCookieHeader(requestHeaders?.cookie || requestHeaders?.get?.('cookie')) |
| if (!user) throw new Error('unauthorized') |
| if (documentName && !store.canAccessDoc(documentName, user.username)) throw new Error('no access to this document') |
| return { user } |
| } |
|
|
| export const hocuspocus = new Hocuspocus({ |
| debounce: 2000, |
| maxDebounce: 10000, |
|
|
| async onAuthenticate(data) { |
| return authenticate(data) |
| }, |
|
|
| async onLoadDocument({ documentName, document }) { |
| const state = store.loadDocState(documentName) |
| if (state) Y.applyUpdate(document, state) |
| return document |
| }, |
|
|
| async afterLoadDocument({ documentName, document }) { |
| attachWatcher(documentName, document) |
| |
| setTimeout(() => scanDocForMentions(documentName, document), 0) |
| |
| |
| |
| |
| if (!migratedAnchorDocs.has(documentName)) { |
| migratedAnchorDocs.add(documentName) |
| setTimeout(() => { |
| try { |
| migrateLegacySuggestionAnchors(document) |
| } catch {} |
| try { |
| migrateCheckboxLists(document) |
| } catch {} |
| |
| try { |
| if (String(documentName).endsWith('::_structure') && !document.isDestroyed) { |
| const fragment = document.getXmlFragment(FIELD) |
| let hasCode = false |
| for (let i = 0; i < fragment.length; i++) { |
| if (fragment.get(i) instanceof Y.XmlElement && fragment.get(i).nodeName === 'codeBlock') hasCode = true |
| } |
| if (hasCode) { |
| document.transact(() => { |
| for (let i = fragment.length - 1; i >= 0; i--) { |
| const el2 = fragment.get(i) |
| if (!(el2 instanceof Y.XmlElement) || el2.nodeName !== 'codeBlock') fragment.delete(i, 1) |
| } |
| }) |
| } |
| } |
| } catch {} |
| }, 50) |
| } |
| }, |
|
|
| async afterUnloadDocument({ documentName }) { |
| const un = docWatchers.get(documentName) |
| if (un) { |
| un() |
| docWatchers.delete(documentName) |
| } |
| }, |
|
|
| async onStoreDocument({ documentName, document }) { |
| let markdown = null |
| let title = null |
| try { |
| const pm = yDocToProsemirrorJSON(document, FIELD) |
| markdown = pmToMarkdown(pm) |
| title = titleFromPm(pm) |
| } catch {} |
| store.saveDocState(documentName, Y.encodeStateAsUpdate(document), markdown) |
| store.touchProjectOnStore(projectIdOf(documentName), pageSlugOf(documentName), title) |
| }, |
| }) |
|
|
| function titleFromPm(pm) { |
| for (const node of pm.content || []) { |
| const text = blockToMarkdown(node) |
| ?.replace(/^#+\s*/, '') |
| .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') |
| .replace(/[*_`]/g, '') |
| .trim() |
| if (text) return text.slice(0, 80) |
| } |
| return null |
| } |
|
|
| |
|
|
| function attachWatcher(docName, document) { |
| if (docWatchers.has(docName)) return |
| const threads = document.getMap('threads') |
| const observer = () => setTimeout(() => scanDocForMentions(docName, document), 0) |
| threads.observeDeep(observer) |
| docWatchers.set(docName, () => threads.unobserveDeep(observer)) |
| } |
|
|
| function scanDocForMentions(docName, document) { |
| if (document.isDestroyed) return |
| const threads = document.getMap('threads') |
| const agents = store.getAgents() |
| const created = [] |
| const explicitHandles = text => |
| [...new Set([...String(text || '').matchAll(/@([a-z0-9][a-z0-9_.-]{1,38})/g)].map(m => m[1]))].filter(h => agents[h]) |
|
|
| threads.forEach((ythread, threadId) => { |
| const messages = ythread.get('messages') |
| if (!messages) return |
| const all = messages.toArray() |
| all.forEach((msg, msgIdx) => { |
| if (!msg?.id || msg.authorType === 'agent') return |
| const key = `${docName}:${threadId}:${msg.id}` |
| if (mentionState.processed[key]) return |
| mentionState.processed[key] = true |
| |
| |
| const usable = h => |
| store.canAccessDoc(docName, agents[h]?.owner) && (agents[h]?.shared !== false || agents[h]?.owner === msg.author) |
| const explicit = explicitHandles(msg.text) |
| let handles = explicit.filter(usable) |
| let implicit = false |
| |
| |
| if (!explicit.length) { |
| |
| |
| |
| const candidates = new Set() |
| const suggestionId = ythread.get('suggestionId') |
| if (suggestionId) { |
| const sugg = document.getMap('suggestions').get(suggestionId) |
| if (sugg?.authorType === 'agent' && agents[sugg.author]) candidates.add(sugg.author) |
| } |
| for (const prev of all.slice(0, msgIdx)) { |
| if (prev.authorType === 'agent' && agents[prev.author]) candidates.add(prev.author) |
| for (const h of explicitHandles(prev.text)) candidates.add(h) |
| } |
| if (candidates.size === 1) { |
| handles = [...candidates].filter(usable) |
| implicit = true |
| } |
| } |
| const chips = [] |
| for (const handle of handles) { |
| const task = { |
| id: newId(12), |
| docId: docName, |
| threadId, |
| messageId: msg.id, |
| handle, |
| requestedBy: msg.author, |
| text: msg.text, |
| implicit, |
| status: 'pending', |
| attempts: 0, |
| ts: Date.now(), |
| } |
| mentionState.tasks[task.id] = task |
| created.push(task) |
| chips.push({ handle, mentionId: task.id, status: 'pending' }) |
| } |
| if (chips.length) { |
| setTimeout(() => updateMessage(docName, threadId, msg.id, { mentions: chips }).catch(() => {}), 0) |
| } |
| }) |
| }) |
| if (created.length) { |
| persistMentions() |
| wakeWaiters() |
| } |
| } |
|
|
| |
|
|
| async function withDoc(docName, fn) { |
| const conn = await hocuspocus.openDirectConnection(docName, { user: { username: '__server__' } }) |
| try { |
| let result |
| await conn.transact(document => { |
| result = fn(document) |
| }) |
| return result |
| } finally { |
| await conn.disconnect() |
| } |
| } |
|
|
| async function readDoc(docName, fn) { |
| const conn = await hocuspocus.openDirectConnection(docName, { user: { username: '__server__' } }) |
| try { |
| return fn(conn.document) |
| } finally { |
| await conn.disconnect() |
| } |
| } |
|
|
| export async function updateMessage(docName, threadId, messageId, patch) { |
| return withDoc(docName, document => { |
| const ythread = document.getMap('threads').get(threadId) |
| const messages = ythread?.get('messages') |
| if (!messages) return false |
| const idx = messages.toArray().findIndex(m => m?.id === messageId) |
| if (idx === -1) return false |
| const updated = { ...messages.get(idx), ...patch } |
| messages.delete(idx, 1) |
| messages.insert(idx, [updated]) |
| return true |
| }) |
| } |
|
|
| export async function addMessage(docName, threadId, { author, authorType, text }) { |
| const msg = { id: newId(10), author, authorType, text, ts: Date.now() } |
| const ok = await withDoc(docName, document => { |
| const ythread = document.getMap('threads').get(threadId) |
| const messages = ythread?.get('messages') |
| if (!messages) return false |
| messages.push([msg]) |
| return true |
| }) |
| return ok ? msg : null |
| } |
|
|
| export async function setMentionChip(task, status) { |
| try { |
| const ythreadOk = await readDoc(task.docId, document => { |
| const ythread = document.getMap('threads').get(task.threadId) |
| const messages = ythread?.get('messages') |
| if (!messages) return false |
| return messages.toArray().some(m => m?.id === task.messageId) |
| }) |
| if (!ythreadOk) return |
| await withDoc(task.docId, document => { |
| const messages = document.getMap('threads').get(task.threadId).get('messages') |
| const idx = messages.toArray().findIndex(m => m?.id === task.messageId) |
| if (idx === -1) return |
| const msg = { ...messages.get(idx) } |
| msg.mentions = (msg.mentions || []).map(c => (c.mentionId === task.id ? { ...c, status } : c)) |
| messages.delete(idx, 1) |
| messages.insert(idx, [msg]) |
| }) |
| } catch {} |
| } |
|
|
| function setTaskStatus(task, status, extra = {}) { |
| Object.assign(task, { status, ...extra }) |
| persistMentions() |
| setTimeout(() => setMentionChip(task, status), 0) |
| } |
|
|
| |
|
|
| export function handlesOwnedBy(username) { |
| const agents = store.getAgents() |
| return Object.keys(agents).filter(h => agents[h].owner === username) |
| } |
|
|
| function claimable(handles) { |
| return Object.values(mentionState.tasks).filter(t => t.status === 'pending' && handles.includes(t.handle)) |
| } |
|
|
| async function claimAndEnrich(tasks) { |
| const out = [] |
| for (const task of tasks) { |
| setTaskStatus(task, 'claimed', { claimedAt: Date.now(), attempts: (task.attempts || 0) + 1 }) |
| let context = null |
| try { |
| context = await readDoc(task.docId, document => { |
| const ythread = document.getMap('threads').get(task.threadId) |
| const suggestionId = ythread?.get('suggestionId') || null |
| const sugg = suggestionId ? document.getMap('suggestions').get(suggestionId) : null |
| return { |
| excerpt: ythread?.get('excerpt') || null, |
| thread_messages: ythread?.get('messages')?.toArray() || [], |
| suggestion: sugg |
| ? { |
| suggestion_id: sugg.id, |
| author: sugg.author, |
| status: sugg.status, |
| original_markdown: sugg.originalMarkdown, |
| replacement_markdown: sugg.replacementMarkdown, |
| rationale: sugg.rationale, |
| } |
| : null, |
| } |
| }) |
| } catch {} |
| const registry = store.getRegistry() |
| const projectId = projectIdOf(task.docId) |
| const page = pageSlugOf(task.docId) |
| out.push({ |
| mention_id: task.id, |
| doc_id: projectId, |
| page, |
| doc_title: registry[projectId]?.title || projectId, |
| page_title: page === 'home' ? null : store.pagesOf(projectId)?.[page]?.title || page, |
| thread_id: task.threadId, |
| handle: task.handle, |
| requested_by: task.requestedBy, |
| instruction: task.text, |
| anchored_text: context?.excerpt || null, |
| thread_messages: (context?.thread_messages || []).map(m => ({ author: m.author, authorType: m.authorType, text: m.text })), |
| about_suggestion: context?.suggestion || null, |
| implicit_follow_up: !!task.implicit, |
| created_at: task.ts, |
| }) |
| } |
| return out |
| } |
|
|
| function pollableHandles(username, onlyHandle) { |
| let handles = handlesOwnedBy(username) |
| if (onlyHandle) { |
| |
| handles = handles.filter(h => h === onlyHandle) |
| } else { |
| |
| |
| |
| const agents = store.getAgents() |
| handles = handles.filter(h => !agents[h]?.keyHash) |
| } |
| return handles |
| } |
|
|
| const NO_HANDLES_ERROR = 'no agent handles registered for your account (handles with a key must poll with that key) — POST /api/agents first' |
|
|
| |
| |
| export function snapshotMentions(username, onlyHandle = null) { |
| const handles = pollableHandles(username, onlyHandle) |
| if (!handles.length) return { error: NO_HANDLES_ERROR } |
| const mentions = Object.values(mentionState.tasks) |
| .filter(t => handles.includes(t.handle) && (t.status === 'pending' || t.status === 'claimed')) |
| .map(t => ({ mention_id: t.id, doc_id: t.docId, thread_id: t.threadId, handle: t.handle, status: t.status, instruction: t.text, created_at: t.ts })) |
| return { snapshot: true, mentions } |
| } |
|
|
| export function pollMentions(username, waitSeconds, onlyHandle = null, opts = {}) { |
| const handles = pollableHandles(username, onlyHandle) |
| if (!handles.length) return Promise.resolve({ error: NO_HANDLES_ERROR }) |
| for (const h of handles) agentLastPoll.set(h, Date.now()) |
|
|
| const now = claimable(handles) |
| if (now.length || !waitSeconds) return claimAndEnrich(now).then(mentions => ({ mentions })) |
|
|
| const { maxWait = 55, registerCancel = null } = opts |
| return new Promise(resolve => { |
| const waiter = { |
| handles: new Set(handles), |
| done: false, |
| fire: async tasks => { |
| if (waiter.fired) return |
| waiter.fired = true |
| clearTimeout(waiter.timer) |
| const idx = waiters.indexOf(waiter) |
| if (idx !== -1) waiters.splice(idx, 1) |
| for (const h of handles) agentLastPoll.set(h, Date.now()) |
| resolve({ mentions: await claimAndEnrich(tasks) }) |
| }, |
| } |
| waiter.timer = setTimeout(() => waiter.fire([]), Math.min(waitSeconds, maxWait) * 1000) |
| waiters.push(waiter) |
| |
| |
| if (registerCancel) { |
| registerCancel(() => { |
| if (waiter.fired) return |
| waiter.fired = true |
| clearTimeout(waiter.timer) |
| const idx = waiters.indexOf(waiter) |
| if (idx !== -1) waiters.splice(idx, 1) |
| resolve({ mentions: [], cancelled: true }) |
| }) |
| } |
| }) |
| } |
|
|
| function wakeWaiters() { |
| for (const waiter of [...waiters]) { |
| if (waiter.fired) continue |
| const tasks = claimable([...waiter.handles]) |
| if (tasks.length) waiter.fire(tasks) |
| } |
| } |
|
|
| export function agentPresence() { |
| const agents = store.getAgents() |
| return Object.entries(agents).map(([handle, info]) => { |
| const hasWaiter = waiters.some(w => !w.fired && w.handles.has(handle)) |
| const last = agentLastPoll.get(handle) || 0 |
| return { |
| handle, |
| owner: info.owner, |
| shared: info.shared !== false, |
| online: hasWaiter || Date.now() - last < 70 * 1000, |
| last_seen: last || null, |
| } |
| }) |
| } |
|
|
| export function dismissMention(mentionId, username) { |
| const task = mentionState.tasks[mentionId] |
| if (!task) return { error: 'mention not found' } |
| const agents = store.getAgents() |
| if (agents[task.handle]?.owner !== username) return { error: 'not your mention' } |
| if (task.status !== 'pending' && task.status !== 'claimed') return { error: `mention already ${task.status}` } |
| setTaskStatus(task, 'done', { doneAt: Date.now(), dismissed: true }) |
| return { ok: true } |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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() |
|
|
| |
|
|
| export async function getDocSnapshot(docName) { |
| return readDoc(docName, document => { |
| const pm = yDocToProsemirrorJSON(document, FIELD) |
| const fragment = document.getXmlFragment(FIELD) |
| const blocks = (pm.content || []).map((node, i) => ({ |
| index: i, |
| anchor_start: b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, i))), |
| anchor_end: b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, i + 1))), |
| markdown: blockToMarkdown(node) ?? '', |
| })) |
| const threads = [] |
| document.getMap('threads').forEach((ythread, id) => { |
| threads.push({ |
| thread_id: id, |
| excerpt: ythread.get('excerpt') || null, |
| resolved: !!ythread.get('resolved'), |
| messages: (ythread.get('messages')?.toArray() || []).map(m => ({ author: m.author, authorType: m.authorType, text: m.text })), |
| }) |
| }) |
| const suggestions = [] |
| document.getMap('suggestions').forEach(s => suggestions.push(s)) |
| return { |
| markdown: pmToMarkdown(pm), |
| blocks, |
| threads, |
| suggestions: suggestions.map(s => ({ id: s.id, author: s.author, status: s.status, rationale: s.rationale })), |
| } |
| }) |
| } |
|
|
| |
|
|
| export async function createSuggestion(docName, { anchorStart, anchorEnd, blockIndex, replacementMarkdown, rationale, author, authorType, threadId, supersedes }) { |
| return withDoc(docName, document => { |
| |
| |
| let revises = null |
| if (supersedes) { |
| const old = document.getMap('suggestions').get(supersedes) |
| if (old && old.author === author) { |
| revises = supersedes |
| if (anchorStart == null && blockIndex == null) { |
| anchorStart = old.anchorStart |
| anchorEnd = old.anchorEnd |
| } |
| } |
| } |
| const fragment = document.getXmlFragment(FIELD) |
| if (blockIndex != null && (anchorStart == null || anchorEnd == null)) { |
| const i = Math.max(0, Math.min(Number(blockIndex), fragment.length - 1)) |
| anchorStart = b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, i))) |
| anchorEnd = b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, i + 1))) |
| } |
| const range = resolveBlockRange(document, fragment, anchorStart, anchorEnd) |
| if (!range) return { error: 'anchor could not be resolved against the current document' } |
| const pm = yDocToProsemirrorJSON(document, FIELD) |
| const original = (pm.content || []).slice(range.from, range.to).map(blockToMarkdown).join('\n\n') |
| const suggestion = { |
| id: newId(10), |
| anchorStart, |
| anchorEnd, |
| author, |
| authorType, |
| replacementMarkdown: String(replacementMarkdown ?? ''), |
| rationale: rationale ? String(rationale) : null, |
| originalMarkdown: original, |
| status: 'open', |
| revises, |
| threadId: threadId || null, |
| ts: Date.now(), |
| } |
| |
| 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)))) |
| |
| |
| |
| const afterIdx = stableAfterIndex(pm, range.to) |
| suggestion.anchorAfter = b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, afterIdx ?? fragment.length))) |
| document.getMap('suggestions').set(suggestion.id, suggestion) |
| return { suggestion } |
| }) |
| } |
|
|
| |
| |
| |
| 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 |
| } |
|
|
| |
| |
| |
| |
| function resolveBlockRange(document, fragment, anchorStart, anchorEnd, anchorEndIncl = null) { |
| try { |
| const absStart = Y.createAbsolutePositionFromRelativePosition(Y.decodeRelativePosition(b64decode(anchorStart)), document) |
| const absEndRel = anchorEndIncl || anchorEnd |
| const absEnd = Y.createAbsolutePositionFromRelativePosition(Y.decodeRelativePosition(b64decode(absEndRel)), document) |
| if (!absStart || !absEnd || absStart.type !== fragment || absEnd.type !== fragment) return null |
| const from = Math.max(0, Math.min(absStart.index, fragment.length)) |
| const endIdx = anchorEndIncl ? absEnd.index + 1 : absEnd.index |
| const to = Math.max(from, Math.min(Math.max(endIdx, from + 1), fragment.length)) |
| return { from, to } |
| } catch { |
| return null |
| } |
| } |
|
|
| const migratedAnchorDocs = new Set() |
|
|
| function migrateLegacySuggestionAnchors(document) { |
| if (document.isDestroyed) return |
| const suggMap = document.getMap('suggestions') |
| const fragment = document.getXmlFragment(FIELD) |
| let pm = { content: [] } |
| try { |
| pm = yDocToProsemirrorJSON(document, FIELD) |
| } catch {} |
| const updates = [] |
| suggMap.forEach((s, id) => { |
| if (!s || (s.anchorEndIncl && s.anchorAfter) || (s.status !== 'open' && s.status !== 'superseded')) return |
| const range = resolveBlockRange(document, fragment, s.anchorStart, s.anchorEnd, s.anchorEndIncl || null) |
| if (!range) return |
| updates.push([ |
| id, |
| { |
| ...s, |
| anchorStart: b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, range.from))), |
| anchorEndIncl: b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, Math.max(range.from, range.to - 1)))), |
| anchorAfter: b64encode( |
| Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(fragment, stableAfterIndex(pm, range.to) ?? fragment.length)) |
| ), |
| }, |
| ]) |
| }) |
| if (updates.length) { |
| document.transact(() => { |
| for (const [id, value] of updates) suggMap.set(id, value) |
| }) |
| } |
| } |
|
|
| export async function resolveSuggestionAction(docName, suggestionId, action, username, { markOnly = false } = {}) { |
| return withDoc(docName, document => { |
| const suggestions = document.getMap('suggestions') |
| const suggestion = suggestions.get(suggestionId) |
| if (!suggestion) return { error: 'suggestion not found' } |
|
|
| if (action === 'reopen') { |
| |
| if (suggestion.status !== 'accepted') return { error: `cannot reopen a ${suggestion.status} suggestion` } |
| suggestions.set(suggestionId, { ...suggestion, status: 'open', resolvedBy: null, resolvedAt: null }) |
| return { ok: true } |
| } |
|
|
| if (suggestion.status !== 'open' && suggestion.status !== 'superseded') return { error: `suggestion already ${suggestion.status}` } |
|
|
| if (action === 'reject') { |
| suggestions.set(suggestionId, { ...suggestion, status: 'rejected', resolvedBy: username, resolvedAt: Date.now() }) |
| return { ok: true } |
| } |
|
|
| if (markOnly) { |
| |
| |
| suggestions.set(suggestionId, { ...suggestion, status: 'accepted', resolvedBy: username, resolvedAt: Date.now() }) |
| return { ok: true } |
| } |
|
|
| const fragment = document.getXmlFragment(FIELD) |
| const range = resolveBlockRange(document, fragment, suggestion.anchorStart, suggestion.anchorEnd, suggestion.anchorEndIncl || null) |
| if (!range) { |
| suggestions.set(suggestionId, { ...suggestion, status: 'orphaned' }) |
| return { error: 'the anchored text no longer exists; suggestion marked orphaned' } |
| } |
| const nodes = markdownToBlocks(suggestion.replacementMarkdown) |
| fragment.delete(range.from, range.to - range.from) |
| fragment.insert(range.from, nodes.map(buildYBlock)) |
| suggestions.set(suggestionId, { ...suggestion, status: 'accepted', resolvedBy: username, resolvedAt: Date.now() }) |
| if (String(docName).endsWith('::_structure')) clearStructureCache(projectIdOf(docName)) |
| return { ok: true } |
| }) |
| } |
|
|
| |
|
|
| function yInlineText(segments) { |
| |
| |
| |
| const t = new Y.XmlText() |
| t.insert(0, (segments || []).map(s => s.text).join('')) |
| let pos = 0 |
| for (const seg of segments || []) { |
| if (Object.keys(seg.attrs || {}).length) t.format(pos, seg.text.length, seg.attrs) |
| pos += seg.text.length |
| } |
| return t |
| } |
|
|
| function paragraphOf(segments) { |
| const p = new Y.XmlElement('paragraph') |
| p.insert(0, [yInlineText(segments)]) |
| return p |
| } |
|
|
| export function buildYBlock(block) { |
| switch (block.type) { |
| case 'heading': { |
| const el = new Y.XmlElement('heading') |
| el.setAttribute('level', block.attrs?.level || 1) |
| el.insert(0, [yInlineText(block.inline)]) |
| return el |
| } |
| case 'codeBlock': { |
| const el = new Y.XmlElement('codeBlock') |
| if (block.attrs?.language) el.setAttribute('language', block.attrs.language) |
| const t = new Y.XmlText() |
| t.insert(0, block.text || '') |
| el.insert(0, [t]) |
| return el |
| } |
| case 'bulletList': |
| case 'orderedList': { |
| const el = new Y.XmlElement(block.type) |
| el.insert(0, (block.items || []).map(segments => { |
| const li = new Y.XmlElement('listItem') |
| li.insert(0, [paragraphOf(segments)]) |
| return li |
| })) |
| return el |
| } |
| case 'taskList': { |
| const el = new Y.XmlElement('taskList') |
| el.insert(0, (block.items || []).map(it => { |
| const li = new Y.XmlElement('taskItem') |
| li.setAttribute('checked', !!it.checked) |
| li.insert(0, [paragraphOf(it.inline)]) |
| return li |
| })) |
| return el |
| } |
| case 'table': { |
| const el = new Y.XmlElement('table') |
| el.insert(0, (block.rows || []).map((cells, r) => { |
| const row = new Y.XmlElement('tableRow') |
| row.insert(0, cells.map(segs => { |
| const cell = new Y.XmlElement(r === 0 ? 'tableHeader' : 'tableCell') |
| cell.setAttribute('colspan', 1) |
| cell.setAttribute('rowspan', 1) |
| cell.insert(0, [paragraphOf(segs)]) |
| return cell |
| })) |
| return row |
| })) |
| return el |
| } |
| case 'blockquote': { |
| const el = new Y.XmlElement('blockquote') |
| el.insert(0, [paragraphOf(block.inline)]) |
| return el |
| } |
| case 'horizontalRule': |
| return new Y.XmlElement('horizontalRule') |
| case 'image': { |
| const el = new Y.XmlElement('image') |
| el.setAttribute('src', block.attrs?.src || '') |
| if (block.attrs?.alt) el.setAttribute('alt', block.attrs.alt) |
| return el |
| } |
| default: |
| return paragraphOf(block.inline) |
| } |
| } |
|
|
| export async function deleteDoc(id) { |
| const pages = Object.keys(store.pagesOf(id) || { home: {} }) |
| for (const slug of [...new Set(['home', '_structure', ...pages])]) { |
| const name = docNameFor(id, slug) |
| try { hocuspocus.closeConnections(name) } catch {} |
| const un = docWatchers.get(name) |
| if (un) { |
| un() |
| docWatchers.delete(name) |
| } |
| } |
| for (const [taskId, task] of Object.entries(mentionState.tasks)) { |
| if (projectIdOf(task.docId) === id) delete mentionState.tasks[taskId] |
| } |
| persistMentions() |
| store.deleteDocFiles(id) |
| } |
|
|
| |
|
|
| 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 |
| } |
|
|
| |
|
|
| function structureCodeBlock(document) { |
| const fragment = document.getXmlFragment(FIELD) |
| for (let i = 0; i < fragment.length; i++) { |
| const el = fragment.get(i) |
| if (el instanceof Y.XmlElement && el.nodeName === 'codeBlock') return el |
| } |
| return null |
| } |
|
|
| const ensuredStructure = new Set() |
|
|
| export async function ensureStructurePage(projectId) { |
| if (ensuredStructure.has(projectId)) return |
| const name = docNameFor(projectId, '_structure') |
| if (store.loadDocState(name)) { |
| ensuredStructure.add(projectId) |
| return |
| } |
| const slugs = Object.keys(store.pagesOf(projectId) || { home: {} }) |
| await withDoc(name, document => { |
| const fragment = document.getXmlFragment(FIELD) |
| if (fragment.length > 0) return |
| |
| const code = new Y.XmlElement('codeBlock') |
| code.setAttribute('language', 'yaml') |
| const ct = new Y.XmlText() |
| ct.insert(0, defaultStructureYaml(slugs)) |
| code.insert(0, [ct]) |
| fragment.insert(0, [code]) |
| }) |
| store.upsertPageMeta(projectId, '_structure', { title: 'Structure', createdAt: Date.now() }) |
| ensuredStructure.add(projectId) |
| clearStructureCache(projectId) |
| } |
|
|
| export async function createPage(projectId, title, username, { slug: explicitSlug = null, contentMarkdown = null } = {}) { |
| const pages = store.pagesOf(projectId) || {} |
| let slug |
| if (explicitSlug) { |
| |
| slug = String(explicitSlug) |
| if (!PAGE_SLUG_RE.test(slug) || slug === '_structure' || slug === 'home') return { error: 'bad page slug' } |
| if (pages[slug]) return { error: 'page already exists' } |
| } else { |
| const base = slugify(title) |
| slug = base === '_structure' ? 'page' : base |
| let n = 2 |
| while (pages[slug] || slug === 'home') slug = `${base}-${n++}`.slice(0, 40) |
| if (!PAGE_SLUG_RE.test(slug) || slug === '_structure') return { error: 'bad page title' } |
| } |
| const name = docNameFor(projectId, slug) |
| await withDoc(name, document => { |
| const fragment = document.getXmlFragment(FIELD) |
| if (fragment.length > 0) return |
| if (contentMarkdown != null && String(contentMarkdown).trim()) { |
| |
| fragment.insert(0, markdownToBlocks(contentMarkdown).map(buildYBlock)) |
| } else { |
| const h = new Y.XmlElement('heading') |
| h.setAttribute('level', 1) |
| const t = new Y.XmlText() |
| t.insert(0, title || slug) |
| h.insert(0, [t]) |
| const p = new Y.XmlElement('paragraph') |
| fragment.insert(0, [h, p]) |
| } |
| }) |
| store.upsertPageMeta(projectId, slug, { title: title || slug, createdAt: Date.now(), createdBy: username }) |
| await ensureStructurePage(projectId) |
| |
| let newRaw = null |
| await withDoc(docNameFor(projectId, '_structure'), document => { |
| const code = structureCodeBlock(document) |
| if (!code) return |
| const text = code.get(0) |
| if (!(text instanceof Y.XmlText)) return |
| const current = text.toString() |
| if (new RegExp(`^\\s*-\\s*${slug}\\s*$`, 'm').test(current)) return |
| text.insert(current.length, `${current.endsWith('\n') || !current ? '' : '\n'}- ${slug}`) |
| newRaw = text.toString() |
| }) |
| if (newRaw != null) primeStructureCache(projectId, newRaw) |
| else clearStructureCache(projectId) |
| return { slug } |
| } |
|
|
| export async function deletePage(projectId, slug) { |
| if (slug === 'home' || slug === '_structure') return { error: 'this page cannot be deleted' } |
| const name = docNameFor(projectId, slug) |
| try { hocuspocus.closeConnections(name) } catch {} |
| const un = docWatchers.get(name) |
| if (un) { |
| un() |
| docWatchers.delete(name) |
| } |
| for (const [taskId, task] of Object.entries(mentionState.tasks)) { |
| if (task.docId === name) delete mentionState.tasks[taskId] |
| } |
| persistMentions() |
| store.deletePageFiles(name) |
| store.removePageMeta(projectId, slug) |
| |
| let newRaw = null |
| try { |
| await withDoc(docNameFor(projectId, '_structure'), document => { |
| const code = structureCodeBlock(document) |
| const text = code?.get(0) |
| if (!(text instanceof Y.XmlText)) return |
| const lines = text.toString().split('\n') |
| const keep = lines.filter(l => !new RegExp(`^\\s*-\\s*${slug}\\s*:?\\s*$`).test(l)) |
| if (keep.length !== lines.length) { |
| text.delete(0, text.length) |
| text.insert(0, keep.join('\n')) |
| } |
| newRaw = text.toString() |
| }) |
| } catch {} |
| if (newRaw != null) primeStructureCache(projectId, newRaw) |
| else clearStructureCache(projectId) |
| return { ok: true } |
| } |
|
|
| |
| |
| |
| |
| function migrateCheckboxLists(document) { |
| if (document.isDestroyed) return |
| const fragment = document.getXmlFragment(FIELD) |
| let pm |
| try { |
| pm = yDocToProsemirrorJSON(document, FIELD) |
| } catch { |
| return |
| } |
| const content = pm.content || [] |
| const CHECKBOX = /^\[[ xX]?\]\s+\S/ |
| const itemText = li => (li.content || []).map(b => (b.content || []).map(n => n.text || '').join('')).join(' ') |
| const updates = [] |
| content.forEach((node, i) => { |
| if (node.type !== 'bulletList') return |
| const items = node.content || [] |
| if (items.length && items.every(li => CHECKBOX.test(itemText(li)))) { |
| const blocks = markdownToBlocks(blockToMarkdown(node)) |
| if (blocks.length === 1 && blocks[0].type === 'taskList') updates.push([i, blocks[0], itemText(items[0])]) |
| } |
| }) |
| if (!updates.length) return |
| document.transact(() => { |
| |
| |
| |
| 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)]) |
| } |
| }) |
| } |
|
|
| |
| |
| |
| |
|
|
| export async function createPageProposal(projectId, { title, contentMarkdown, rationale, author, authorType }) { |
| const clean = String(title || '').trim().slice(0, 120) |
| if (!clean) return { error: 'title required' } |
| const pages = store.pagesOf(projectId) || {} |
| await ensureStructurePage(projectId) |
| const id = newId(10) |
| let existingSlugs = new Set(Object.keys(pages)) |
| const result = await withDoc(docNameFor(projectId, '_structure'), document => { |
| const map = document.getMap('pageProposals') |
| map.forEach(p => { |
| if (p.status === 'open') existingSlugs.add(p.slug) |
| }) |
| const base = slugify(clean) |
| let slug = base === '_structure' ? 'page' : base |
| let n = 2 |
| while (existingSlugs.has(slug) || slug === 'home') slug = `${base}-${n++}`.slice(0, 40) |
| map.set(id, { |
| id, |
| kind: 'newPage', |
| slug, |
| title: clean, |
| contentMarkdown: String(contentMarkdown || ''), |
| rationale: rationale ? String(rationale) : null, |
| author, |
| authorType, |
| status: 'open', |
| ts: Date.now(), |
| }) |
| return { slug } |
| }) |
| clearStructureCache(projectId) |
| return { id, slug: result.slug } |
| } |
|
|
| export async function getPageProposal(projectId, pid) { |
| return readDoc(docNameFor(projectId, '_structure'), document => { |
| const p = document.getMap('pageProposals').get(pid) |
| return p || null |
| }) |
| } |
|
|
| export async function resolvePageProposal(projectId, pid, action, username) { |
| const proposal = await getPageProposal(projectId, pid) |
| if (!proposal) return { error: 'proposal not found' } |
| if (proposal.status !== 'open') return { error: `proposal already ${proposal.status}` } |
| if (action === 'reject') { |
| await withDoc(docNameFor(projectId, '_structure'), document => { |
| const map = document.getMap('pageProposals') |
| const p = map.get(pid) |
| if (p) map.set(pid, { ...p, status: 'rejected', resolvedBy: username, resolvedAt: Date.now() }) |
| }) |
| clearStructureCache(projectId) |
| return { ok: true } |
| } |
| |
| const created = await createPage(projectId, proposal.title, username, { |
| slug: proposal.slug, |
| contentMarkdown: proposal.contentMarkdown, |
| }) |
| if (created.error) return created |
| await withDoc(docNameFor(projectId, '_structure'), document => { |
| const map = document.getMap('pageProposals') |
| const p = map.get(pid) |
| if (p) map.set(pid, { ...p, status: 'accepted', resolvedBy: username, resolvedAt: Date.now(), slug: created.slug }) |
| }) |
| clearStructureCache(projectId) |
| return { ok: true, slug: created.slug } |
| } |
|
|