document-tree.mjs
sha256:8d46372e39d2d5a54fd93a8b1c27922fe0d9b22a72197345f1d2c71701cc4ce2
feat(auth): persistent login system + C7 session introspection
Human
minor
⚠ breaking
16 days ago
| 1 | /** |
| 2 | * Derived read-only tree for a single Markdown note outline. |
| 3 | * |
| 4 | * This module intentionally has no file reads, storage, search, vector, memory, |
| 5 | * MCP, Hub, summary, PageIndex, or OCR behavior. It converts an existing |
| 6 | * heading-only NoteOutline shape into a nested data-only DocumentTree. |
| 7 | */ |
| 8 | |
| 9 | import path from 'path'; |
| 10 | import { |
| 11 | buildNoteOutline, |
| 12 | buildNoteOutlineFromMarkdown, |
| 13 | MAX_NOTE_OUTLINE_HEADINGS, |
| 14 | } from './note-outline.mjs'; |
| 15 | |
| 16 | export const DOCUMENT_TREE_SCHEMA = 'knowtation.document_tree/v0'; |
| 17 | |
| 18 | /** |
| 19 | * Build a DocumentTree from a raw Markdown file string. |
| 20 | * @param {string} notePath |
| 21 | * @param {string} markdown |
| 22 | * @param {{ maxInputChars?: number, maxHeadings?: number }} [options] |
| 23 | * @returns {{ schema: string, path: string, title: string|null, root: { children: { id: string, level: number, text: string, children: unknown[] }[] }, truncated: boolean }} |
| 24 | */ |
| 25 | export function buildDocumentTreeFromMarkdown(notePath, markdown, options = {}) { |
| 26 | return buildDocumentTreeFromOutline( |
| 27 | buildNoteOutlineFromMarkdown(notePath, markdown, options) |
| 28 | ); |
| 29 | } |
| 30 | |
| 31 | /** |
| 32 | * Build a DocumentTree from a parsed vault note. |
| 33 | * @param {{ path: string, frontmatter?: Record<string, unknown>, body: string }} note |
| 34 | * @param {{ maxInputChars?: number, maxHeadings?: number }} [options] |
| 35 | * @returns {{ schema: string, path: string, title: string|null, root: { children: { id: string, level: number, text: string, children: unknown[] }[] }, truncated: boolean }} |
| 36 | */ |
| 37 | export function buildDocumentTree(note, options = {}) { |
| 38 | return buildDocumentTreeFromOutline(buildNoteOutline(note, options)); |
| 39 | } |
| 40 | |
| 41 | /** |
| 42 | * Build a DocumentTree from a NoteOutline-compatible object. |
| 43 | * @param {{ path: string, title: string|null, headings: { level: number, text: string, id: string }[], truncated: boolean }} outline |
| 44 | * @param {{ maxHeadings?: number }} [options] |
| 45 | * @returns {{ schema: string, path: string, title: string|null, root: { children: { id: string, level: number, text: string, children: unknown[] }[] }, truncated: boolean }} |
| 46 | */ |
| 47 | export function buildDocumentTreeFromOutline(outline, options = {}) { |
| 48 | if (outline == null || typeof outline !== 'object') { |
| 49 | throw new TypeError('buildDocumentTreeFromOutline: outline is required'); |
| 50 | } |
| 51 | |
| 52 | const safePath = normalizeTreePath(outline.path); |
| 53 | const title = normalizeTitle(outline.title); |
| 54 | const { headings, truncated } = normalizeHeadings(outline.headings, options); |
| 55 | const root = { children: [] }; |
| 56 | const stack = [{ level: 0, node: root }]; |
| 57 | |
| 58 | for (const heading of headings) { |
| 59 | const node = { |
| 60 | id: heading.id, |
| 61 | level: heading.level, |
| 62 | text: heading.text, |
| 63 | children: [], |
| 64 | }; |
| 65 | |
| 66 | while (stack.length > 1 && stack[stack.length - 1].level >= heading.level) { |
| 67 | stack.pop(); |
| 68 | } |
| 69 | |
| 70 | stack[stack.length - 1].node.children.push(node); |
| 71 | stack.push({ level: heading.level, node }); |
| 72 | } |
| 73 | |
| 74 | return { |
| 75 | schema: DOCUMENT_TREE_SCHEMA, |
| 76 | path: safePath, |
| 77 | title, |
| 78 | root, |
| 79 | truncated: outline.truncated === true || truncated, |
| 80 | }; |
| 81 | } |
| 82 | |
| 83 | /** |
| 84 | * @param {unknown} rawPath |
| 85 | * @returns {string} |
| 86 | */ |
| 87 | function normalizeTreePath(rawPath) { |
| 88 | if (typeof rawPath !== 'string' || rawPath.trim() === '') { |
| 89 | throw new TypeError('buildDocumentTreeFromOutline: outline.path must be a non-empty string'); |
| 90 | } |
| 91 | const forward = rawPath.trim().replace(/\\/g, '/'); |
| 92 | if (path.isAbsolute(rawPath) || path.isAbsolute(forward) || /^[A-Za-z]:\//.test(forward)) { |
| 93 | throw new Error(`Invalid path: path must be vault-relative (${rawPath})`); |
| 94 | } |
| 95 | const parts = forward.split('/').filter(Boolean); |
| 96 | if (parts.includes('..')) { |
| 97 | throw new Error(`Invalid path: path cannot escape vault (${rawPath})`); |
| 98 | } |
| 99 | return parts.join('/'); |
| 100 | } |
| 101 | |
| 102 | /** |
| 103 | * @param {unknown} title |
| 104 | * @returns {string|null} |
| 105 | */ |
| 106 | function normalizeTitle(title) { |
| 107 | if (title == null) return null; |
| 108 | return String(title); |
| 109 | } |
| 110 | |
| 111 | /** |
| 112 | * @param {unknown} rawHeadings |
| 113 | * @param {{ maxHeadings?: number }} options |
| 114 | * @returns {{ headings: { level: number, text: string, id: string }[], truncated: boolean }} |
| 115 | */ |
| 116 | function normalizeHeadings(rawHeadings, options) { |
| 117 | if (!Array.isArray(rawHeadings)) { |
| 118 | throw new TypeError('buildDocumentTreeFromOutline: outline.headings must be an array'); |
| 119 | } |
| 120 | const maxHeadings = normalizeMaxHeadings(options.maxHeadings); |
| 121 | const visibleHeadings = rawHeadings.slice(0, maxHeadings); |
| 122 | return { |
| 123 | headings: visibleHeadings.map((heading, index) => normalizeHeading(heading, index)), |
| 124 | truncated: rawHeadings.length > maxHeadings, |
| 125 | }; |
| 126 | } |
| 127 | |
| 128 | /** |
| 129 | * @param {unknown} value |
| 130 | * @returns {number} |
| 131 | */ |
| 132 | function normalizeMaxHeadings(value) { |
| 133 | if (value == null) return MAX_NOTE_OUTLINE_HEADINGS; |
| 134 | const n = Number(value); |
| 135 | if (!Number.isInteger(n) || n < 1) { |
| 136 | throw new TypeError('buildDocumentTreeFromOutline: maxHeadings must be a positive integer'); |
| 137 | } |
| 138 | return Math.min(n, MAX_NOTE_OUTLINE_HEADINGS); |
| 139 | } |
| 140 | |
| 141 | /** |
| 142 | * @param {unknown} rawHeading |
| 143 | * @param {number} index |
| 144 | * @returns {{ level: number, text: string, id: string }} |
| 145 | */ |
| 146 | function normalizeHeading(rawHeading, index) { |
| 147 | if (rawHeading == null || typeof rawHeading !== 'object') { |
| 148 | throw new TypeError(`buildDocumentTreeFromOutline: heading at index ${index} must be an object`); |
| 149 | } |
| 150 | const level = Number(rawHeading.level); |
| 151 | if (!Number.isInteger(level) || level < 1 || level > 6) { |
| 152 | throw new TypeError(`buildDocumentTreeFromOutline: heading.level at index ${index} must be 1 through 6`); |
| 153 | } |
| 154 | if (typeof rawHeading.id !== 'string' || rawHeading.id.trim() === '') { |
| 155 | throw new TypeError(`buildDocumentTreeFromOutline: heading.id at index ${index} must be a non-empty string`); |
| 156 | } |
| 157 | if (typeof rawHeading.text !== 'string') { |
| 158 | throw new TypeError(`buildDocumentTreeFromOutline: heading.text at index ${index} must be a string`); |
| 159 | } |
| 160 | return { |
| 161 | level, |
| 162 | text: rawHeading.text, |
| 163 | id: rawHeading.id, |
| 164 | }; |
| 165 | } |
File History
1 commit
sha256:8d46372e39d2d5a54fd93a8b1c27922fe0d9b22a72197345f1d2c71701cc4ce2
feat(auth): persistent login system + C7 session introspection
Human
minor
⚠
16 days ago