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.
89 lines
3.4 KiB
TypeScript
89 lines
3.4 KiB
TypeScript
export const PAGES_CHANGED = 'computer:pages-changed'
|
|
|
|
export const notifyPagesChanged = () => window.dispatchEvent(new Event(PAGES_CHANGED))
|
|
|
|
// Notion-style breadcrumbs: the pages workspace announces which page is open
|
|
// so the top bar can show "computer / Pages / <title>" without refetching.
|
|
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
|
|
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.
|
|
export function nextUntitledTitle(titles: string[]): string {
|
|
const used = new Set<number>()
|
|
for (const t of titles) {
|
|
const m = /^Untitled (\d+)$/.exec(t.trim())
|
|
if (m) used.add(parseInt(m[1], 10))
|
|
}
|
|
let n = 1
|
|
while (used.has(n)) n++
|
|
return `Untitled ${n}`
|
|
}
|