/** Simple content hash for change detection (djb2 variant, base-36 encoded). */ export function contentHash(content: string): string { let hash = 0 for (let i = 0; i < content.length; i++) { const chr = content.charCodeAt(i) hash = (hash << 5) - hash + chr hash |= 0 } return hash.toString(36) } /** * Classify a block's type from its first line content. * Matches the block_type CHECK constraint in the schema. */ export function detectBlockType(content: string): string { const first = content.trimStart() if (/^#{1,6}\s/.test(first)) return "heading" if ( /^TODO\s|^DOING\s|^DONE\s|^LATER\s|^NOW\s|^WAITING\s|^CANCELLED\s/i.test( first, ) ) return "task" if (/^>\s*\[!/.test(first)) return "callout" if (/^>\s/.test(first)) return "blockquote" if (/^```/.test(first)) return "code" if (/^\|.+\|/.test(first)) return "table" if (/^\$\$/.test(first)) return "math" if (/^\w+::\s/.test(first)) return "property" return "paragraph" } /** Extract the first `# Heading 1` from markdown content. */ export function extractTitle(content: string): string { const match = content.match(/^# (.+)/m) return match ? match[1]?.trim() : "" } export interface ExtractedLink { target: string linkType: "page-ref" | "block-ref" | "tag" offset: number } /** * Extract [[Page Name]], ((block-id)), and #tag references from block content. * Returns target text, link type, and character offset within the block. */ export function extractLinks(content: string): ExtractedLink[] { const links: ExtractedLink[] = [] for (const m of content.matchAll(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g)) { links.push({ target: m[1]!, // biome-ignore lint/style/noNonNullAssertion: matchAll guarantees group 1 linkType: "page-ref", offset: m.index, }) } for (const m of content.matchAll(/\(\(([a-zA-Z0-9_-]+)\)\)/g)) { links.push({ target: m[1]!, linkType: "block-ref", offset: m.index }) } for (const m of content.matchAll(/(?:\s|^)#([a-zA-Z0-9_-]+)/g)) { links.push({ target: m[1]!, linkType: "tag", offset: m.index + 1, }) } return links }