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 || ''))
}