Spaces:
Sleeping
Sleeping
File size: 13,259 Bytes
e5e233a 9b49194 e5e233a e5167b8 e5e233a e5167b8 e5e233a e5167b8 e5e233a e5167b8 e5e233a e5167b8 e5e233a e5167b8 86725f7 e5167b8 86725f7 e5167b8 86725f7 e5167b8 86725f7 e5167b8 86725f7 e5167b8 86725f7 e5167b8 e5e233a e5167b8 e5e233a 9b49194 9d479ea 9b49194 9d479ea 9b49194 e5e233a 9d479ea 9b49194 e5e233a 8a3f5f2 e5e233a 8a3f5f2 9b49194 e5e233a 9d479ea e5e233a 9b49194 9d479ea 9b49194 9d479ea aeb6b2b 9d479ea e5e233a 9d479ea 8a3f5f2 e5e233a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 | import * as Y from 'yjs'
import { yDocToProsemirrorJSON } from 'y-prosemirror'
import * as store from './store.js'
import { pageSlugOf, projectIdOf, PAGE_SLUG_RE } from './util.js'
const FIELD = 'default'
// --- structure YAML ---
// The `_structure` page holds one YAML code block: an indented dash-list.
// A bare slug is a page; a quoted label is a group header (a purely visual
// separator in the sidebar — no page behind it). Nesting (2 spaces per level)
// makes an entry a child of the one above it. Pages missing from the YAML are
// listed as "unfiled" in the sidebar, so a broken edit never hides content.
//
// - home
// - "Getting started":
// - intro
// - design
// - communication
// - references
const PAGE_LINE_RE = /^(\s*)-\s*([A-Za-z0-9_-]+)\s*:?\s*$/
const GROUP_LINE_RE = /^(\s*)-\s*"([^"]+)"\s*:?\s*$/
export function parseStructure(text) {
const root = { children: [] }
const stack = [{ depth: -1, node: root }]
const problems = []
for (const rawLine of String(text || '').split('\n')) {
if (!rawLine.trim() || rawLine.trim().startsWith('#')) continue
const g = rawLine.match(GROUP_LINE_RE)
const m = g || rawLine.match(PAGE_LINE_RE)
if (!m) {
problems.push(rawLine.trim().slice(0, 60))
continue
}
const depth = Math.floor(m[1].length / 2)
const node = g ? { group: g[2], children: [] } : { slug: m[2], children: [] }
while (stack.length > 1 && stack[stack.length - 1].depth >= depth) stack.pop()
stack[stack.length - 1].node.children.push(node)
stack.push({ depth, node })
}
return { tree: root.children, problems }
}
// --- structure mutations (drag-and-drop, group CRUD) ---
// Pure text→text edits so the sidebar UI never rewrites the YAML wholesale:
// each operation moves/inserts/removes the minimal set of lines, leaving
// comments and hand-formatting elsewhere untouched. Nodes are addressed by
// slug (pages — unique per project) or by path array for groups, where page
// elements are bare slugs and group elements keep their quotes: ['design', '"Ideas"'].
// Line-level parse: every page/group line with its depth, parent, and the
// [line, blockEnd) range of the subtree that travels with it when moved.
function parseItems(raw) {
const lines = String(raw || '').split('\n')
const items = []
const stack = [] // indexes into items
for (let i = 0; i < lines.length; i++) {
if (!lines[i].trim() || lines[i].trim().startsWith('#')) continue
const g = lines[i].match(GROUP_LINE_RE)
const m = g || lines[i].match(PAGE_LINE_RE)
if (!m) continue
const depth = Math.floor(m[1].length / 2)
while (stack.length && items[stack[stack.length - 1]].depth >= depth) stack.pop()
const parentIdx = stack.length ? stack[stack.length - 1] : -1
const el = g ? `"${g[2]}"` : m[2]
const path = parentIdx === -1 ? [el] : [...items[parentIdx].path, el]
items.push({ i, depth, kind: g ? 'group' : 'page', slug: g ? null : m[2], label: g ? g[2] : null, parentIdx, path, blockEnd: lines.length })
stack.push(items.length - 1)
}
for (let a = 0; a < items.length; a++) {
for (let b = a + 1; b < items.length; b++) {
if (items[b].depth <= items[a].depth) {
items[a].blockEnd = items[b].i
break
}
}
}
return { lines, items }
}
const samePath = (a, b) => a.length === b.length && a.every((x, i) => x === b[i])
// ref: slug string (page) or path array (group or page-by-path)
function findByRef(items, ref) {
if (typeof ref === 'string') return items.find(it => it.kind === 'page' && it.slug === ref) || null
if (Array.isArray(ref) && ref.length && ref.every(e => typeof e === 'string')) return items.find(it => samePath(it.path, ref)) || null
return null
}
const childrenOf = (items, parentIdx) => items.filter(it => it.parentIdx === parentIdx)
// Where to splice for "child `index` of `parent`" (parent = item or null for
// root), on an already-parsed doc. Returns { line, depth }.
function insertionPoint(lines, items, parent, index) {
const kids = childrenOf(items, parent ? items.indexOf(parent) : -1)
const n = Number.isInteger(index) ? index : Infinity
if (n >= 0 && n < kids.length) return { line: kids[n].i, depth: parent ? parent.depth + 1 : 0 }
if (!parent) return { line: lines.length, depth: 0 }
return { line: parent.blockEnd, depth: parent.depth + 1 }
}
function shiftIndent(line, deltaLevels) {
if (!line.trim()) return line
const lead = line.match(/^ */)[0].length
return ' '.repeat(Math.max(0, lead + deltaLevels * 2)) + line.trimStart()
}
export function validGroupLabel(label) {
const clean = String(label ?? '').trim()
if (!clean || clean.length > 60 || clean.includes('"') || clean.includes('\n')) return null
return clean
}
// Move pages/groups (each with its whole subtree) under `parentRef` (null =
// root) at `index` among the new parent's children, counted after every moved
// node leaves its old spot. Multiple refs (sidebar multi-select) land as
// consecutive siblings, in document order; a ref nested inside another moved
// ref's subtree travels with it and is dropped from the list. A page slug
// absent from the YAML (unfiled) gets a new line. Returns { raw } or { error }.
// nodeRefs is always a LIST of refs (a bare ref is ambiguous: a path array
// reads as a list of slugs) — single-node callers wrap in an array.
export function moveNodesInYaml(raw, nodeRefs, parentRef, index) {
const refs = Array.isArray(nodeRefs) ? nodeRefs : []
if (!refs.length) return { error: 'no nodes' }
const { lines, items } = parseItems(raw)
const resolved = []
const seen = new Set()
for (const ref of refs) {
if (typeof ref === 'string' && (ref === '_structure' || !PAGE_SLUG_RE.test(ref))) return { error: 'bad node' }
const it = findByRef(items, ref)
if (!it && typeof ref !== 'string') return { error: 'unknown node' }
const id = it ? `l${it.i}` : `s${ref}`
if (seen.has(id)) continue
seen.add(id)
resolved.push({ ref, it })
}
const inMovedBlock = pos => resolved.some(r => r.it && pos > r.it.i && pos < r.it.blockEnd)
// roots only: a selected node inside another selected subtree already travels
const roots = resolved.filter(r => !r.it || !inMovedBlock(r.it.i))
if (parentRef != null) {
const parent = findByRef(items, parentRef)
if (!parent) return { error: 'unknown parent' }
if (roots.some(r => r.it && parent.i >= r.it.i && parent.i < r.it.blockEnd)) return { error: 'cannot nest a node inside itself' }
}
// blocks in document order; unfiled (lineless) refs keep their given order at the end
const inTree = roots.filter(r => r.it).sort((a, b) => a.it.i - b.it.i)
const blocks = [
...inTree.map(r => ({ block: lines.slice(r.it.i, r.it.blockEnd), depth: r.it.depth })),
...roots.filter(r => !r.it).map(r => ({ block: [`- ${r.ref}`], depth: 0 })),
]
const rest = lines.slice()
for (const r of [...inTree].reverse()) rest.splice(r.it.i, r.it.blockEnd - r.it.i)
const after = parseItems(rest.join('\n'))
const parent = parentRef == null ? null : findByRef(after.items, parentRef)
if (parentRef != null && !parent) return { error: 'unknown parent' }
const { line, depth } = insertionPoint(after.lines, after.items, parent, index)
const moved = blocks.flatMap(b => b.block.map(l => shiftIndent(l, depth - b.depth)))
const out = [...after.lines.slice(0, line), ...moved, ...after.lines.slice(line)]
return { raw: out.join('\n') }
}
export function moveNodeInYaml(raw, nodeRef, parentRef, index) {
return moveNodesInYaml(raw, [nodeRef], parentRef, index)
}
export function addGroupToYaml(raw, label, parentRef, index) {
const clean = validGroupLabel(label)
if (!clean) return { error: 'bad group label' }
const { lines, items } = parseItems(raw)
const parent = parentRef == null ? null : findByRef(items, parentRef)
if (parentRef != null && !parent) return { error: 'unknown parent' }
const siblings = childrenOf(items, parent ? items.indexOf(parent) : -1)
if (siblings.some(s => s.kind === 'group' && s.label === clean)) return { error: 'a group with this label already exists here' }
const { line, depth } = insertionPoint(lines, items, parent, index)
const out = [...lines.slice(0, line), `${' '.repeat(depth)}- "${clean}":`, ...lines.slice(line)]
return { raw: out.join('\n') }
}
export function renameGroupInYaml(raw, path, label) {
const clean = validGroupLabel(label)
if (!clean) return { error: 'bad group label' }
const { lines, items } = parseItems(raw)
const node = findByRef(items, path)
if (!node || node.kind !== 'group') return { error: 'unknown group' }
const siblings = childrenOf(items, node.parentIdx)
if (siblings.some(s => s !== node && s.kind === 'group' && s.label === clean)) return { error: 'a group with this label already exists here' }
lines[node.i] = `${' '.repeat(node.depth)}- "${clean}":`
return { raw: lines.join('\n') }
}
// Remove a group header; its children are promoted one level, not deleted.
export function dissolveGroupInYaml(raw, path) {
const { lines, items } = parseItems(raw)
const node = findByRef(items, path)
if (!node || node.kind !== 'group') return { error: 'unknown group' }
const body = lines.slice(node.i + 1, node.blockEnd).map(l => shiftIndent(l, -1))
const out = [...lines.slice(0, node.i), ...body, ...lines.slice(node.blockEnd)]
return { raw: out.join('\n') }
}
export function defaultStructureYaml(slugs) {
const rest = slugs.filter(s => s !== 'home' && s !== '_structure')
return ['- home', ...rest.map(s => `- ${s}`)].join('\n')
}
function slugsInTree(tree, out = new Set()) {
for (const n of tree) {
if (n.slug) out.add(n.slug)
slugsInTree(n.children, out)
}
return out
}
const pageField = docName => {
const slug = pageSlugOf(docName)
return slug === 'home' ? FIELD : `page:${slug}`
}
// Every page is a named fragment of the project's Yjs doc — live if the
// project is open, otherwise reconstructed from its one persisted update.
function pageYDoc(hocuspocus, docName) {
const projectId = projectIdOf(docName)
const live = hocuspocus.documents.get(projectId)
if (live) return live
const state = store.loadDocState(projectId)
if (!state) return null
const doc = new Y.Doc()
Y.applyUpdate(doc, state)
return doc
}
function readPageState(hocuspocus, docName) {
const doc = pageYDoc(hocuspocus, docName)
return doc ? yDocToProsemirrorJSON(doc, pageField(docName)) : null
}
function codeBlockText(pm) {
for (const node of pm?.content || []) {
if (node.type === 'codeBlock') return (node.content || []).map(c => c.text || '').join('')
}
return null
}
// short TTL cache: the sidebar polls this and every miss is a sync read from
// bucket-mounted storage
const structureCache = new Map() // projectId -> { ts, value }
const STRUCTURE_TTL = 5000
export function clearStructureCache(projectId) {
structureCache.delete(projectId)
}
// After a server-side structure mutation, the doc store to disk is debounced —
// prime the cache with the known new yaml so reads are immediately consistent.
export function primeStructureCache(projectId, raw) {
const pages = store.pagesOf(projectId)
if (!pages) return
const { tree, problems } = parseStructure(raw)
const inTree = slugsInTree(tree)
const unfiled = Object.keys(pages).filter(s => s !== '_structure' && !inTree.has(s))
const titles = {}
for (const [slug, meta] of Object.entries(pages)) titles[slug] = meta.title || slug
structureCache.set(projectId, { ts: Date.now(), value: { tree, unfiled, titles, raw, problems } })
}
export function getProjectStructure(hocuspocus, projectId) {
const hit = structureCache.get(projectId)
if (hit && Date.now() - hit.ts < STRUCTURE_TTL && !hocuspocus.documents.has(projectId)) return hit.value
const pages = store.pagesOf(projectId)
if (!pages) return null
const slugs = Object.keys(pages)
let raw = null
const pending = []
try {
const structureName = `${projectId}::_structure`
const sdoc = pageYDoc(hocuspocus, structureName)
if (sdoc) {
raw = codeBlockText(yDocToProsemirrorJSON(sdoc, pageField(structureName)))
sdoc.getMap('pageProposals').forEach(p => {
if (p?.status === 'open') {
pending.push({
id: p.id,
slug: p.slug,
title: p.title,
author: p.author,
author_type: p.authorType,
rationale: p.rationale,
// proposed placement, so the tree can show it where it would land
parent: p.parent ?? null,
index: Number.isInteger(p.index) ? p.index : null,
})
}
})
}
} catch {}
if (raw == null) raw = defaultStructureYaml(slugs)
const { tree, problems } = parseStructure(raw)
const inTree = slugsInTree(tree)
const unfiled = slugs.filter(s => s !== '_structure' && !inTree.has(s))
const titles = {}
for (const [slug, meta] of Object.entries(pages)) titles[slug] = meta.title || slug
const value = { tree, unfiled, titles, raw, problems, pending }
structureCache.set(projectId, { ts: Date.now(), value })
return value
}
export function isValidSlug(slug) {
return PAGE_SLUG_RE.test(String(slug || ''))
}
|