Add connected pages: hierarchy, backlinks, and live embeds
release gate / check (push) Failing after 7m7s
release gate / check (push) Failing after 7m7s
Implement Notion-like upgrade phase 3: owner-scoped nested pages with cycle rejection and child promotion on delete; Markdown-derived internal links and backlinks; live collection embeds resolved in the content-save transaction. Add migration 008, API/docs, integration coverage, and page/sidebar UI.
This commit is contained in:
+64
-1
@@ -9,7 +9,70 @@ export const ACTIVE_PAGE_CHANGED = 'computer:active-page-changed'
|
||||
export const notifyActivePage = (title: string | null) =>
|
||||
window.dispatchEvent(new CustomEvent<string | null>(ACTIVE_PAGE_CHANGED, { detail: title }))
|
||||
|
||||
export type PageSummary = { id: string; title: string; pinned: boolean; position: number; _count?: { bookmarks: number } }
|
||||
export type PageSummary = {
|
||||
id: string
|
||||
title: string
|
||||
pinned: boolean
|
||||
position: number
|
||||
parentId: string | null
|
||||
_count?: { bookmarks: number; children?: number }
|
||||
}
|
||||
|
||||
// Connected pages (phase 3): the same link forms the editor understands,
|
||||
// shared by the page client, sidebar and Markdown preview. Page links use
|
||||
// `computer:page/<id>`; /pages?id=<id> is accepted for hand-written links.
|
||||
export function internalPageId(href: string): string | null {
|
||||
const value = href.trim().replace(/^<|>$/g, '')
|
||||
let raw = ''
|
||||
if (value.startsWith('computer://page/')) raw = value.slice('computer://page/'.length)
|
||||
else if (value.startsWith('computer:page/')) raw = value.slice('computer:page/'.length)
|
||||
else if (value.startsWith('page/')) raw = value.slice('page/'.length)
|
||||
else if (value.startsWith('/pages/')) raw = value.slice('/pages/'.length)
|
||||
else if (value.startsWith('/pages?') || value.startsWith('computer:page?id=') || value.startsWith('computer://page?id=')) {
|
||||
try {
|
||||
const id = new URL(value, 'http://localhost').searchParams.get('id')
|
||||
raw = id ?? ''
|
||||
} catch { raw = '' }
|
||||
}
|
||||
raw = raw.replace(/\/$/, '')
|
||||
return /^[A-Za-z0-9_-]{1,128}$/.test(raw) ? raw : null
|
||||
}
|
||||
|
||||
export const pageLinkMarkdown = (page: Pick<PageSummary, 'id' | 'title'>) =>
|
||||
`[${page.title.replace(/[\[\]\n]/g, ' ')}](computer:page/${page.id})`
|
||||
|
||||
export type PageEmbedMarker = { collectionId: string; viewId: string | null }
|
||||
|
||||
export function parsePageEmbed(value: string): PageEmbedMarker | null {
|
||||
const m = /^\{\{\s*collection:([A-Za-z0-9_-]{1,128})(?:\s*\|\s*view=([A-Za-z0-9_-]{1,128}))?\s*\}\}$/.exec(value.trim())
|
||||
if (!m) return null
|
||||
return { collectionId: m[1], viewId: m[2] ?? null }
|
||||
}
|
||||
|
||||
export const collectionEmbedMarkdown = (collectionId: string, viewId?: string | null) =>
|
||||
`{{collection:${collectionId}${viewId ? `|view=${viewId}` : ''}}}`
|
||||
|
||||
// Ids below `id` in the current page tree. Used to keep the parent picker
|
||||
// from offering a descendant (the server rejects the cycle regardless).
|
||||
export function descendantPageIds(pages: PageSummary[], id: string): Set<string> {
|
||||
const out = new Set<string>()
|
||||
const children = new Map<string, string[]>()
|
||||
for (const p of pages) {
|
||||
if (!p.parentId) continue
|
||||
const list = children.get(p.parentId) ?? []
|
||||
list.push(p.id)
|
||||
children.set(p.parentId, list)
|
||||
}
|
||||
const walk = (parent: string) => {
|
||||
for (const child of children.get(parent) ?? []) {
|
||||
if (out.has(child)) continue
|
||||
out.add(child)
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(id)
|
||||
return out
|
||||
}
|
||||
|
||||
// Pages start as "Untitled 1", "Untitled 2", ... until the user renames them.
|
||||
// Pick the lowest positive integer not already used by an untitled page.
|
||||
|
||||
Reference in New Issue
Block a user