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 / " 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}` }