/* ── Obsidian Vault Reader ───────────────────────────────────────────── * File tree navigation, markdown rendering, wiki-links, search. * Relies on server.py's /api/vault/* endpoints. */ const VAULT_API = '/api/vault'; // ── Markdown Renderer ──────────────────────────────────────────────── function escapeHtml(text) { const div = document.createElement('div'); div.appendChild(document.createTextNode(text)); return div.innerHTML; } function renderMarkdown(md) { // Normalise line endings let text = md.replace(/\r\n?/g, '\n'); // Extract code blocks first so they're not mangled by inline rules const blocks = []; text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { const idx = blocks.length; blocks.push(`
${escapeHtml(code.trimEnd())}
`); return `\x00CODEBLOCK${idx}\x00`; }); // Inline code text = text.replace(/`([^`]+)`/g, '$1'); // Wiki-links [[Page Name]] → clickable links text = text.replace(/\[\[([^\]]+)\]\]/g, (_, page) => { const label = page.includes('|') ? page.split('|')[1] : page; const target = page.split('|')[0]; const href = '#' + target.replace(/\s+/g, '-').toLowerCase(); return `${escapeHtml(label)}`; }); // Images ![alt](url) text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); // Links [text](url) text = text.replace(/(?$1'); // Bold/italic: ***bold italic***, **bold**, *italic* text = text.replace(/\*\*\*(.+?)\*\*\*/g, '$1'); text = text.replace(/\*\*(.+?)\*\*/g, '$1'); text = text.replace(/(?$1'); // Strikethrough text = text.replace(/~~(.+?)~~/g, '$1'); // Blockquotes text = text.replace(/^>(.*)$/gm, '
$1
'); text = text.replace(/^>(.*)$/gm, '
$1
'); // Horizontal rules text = text.replace(/^(---|\*\*\*|___)\s*$/gm, '
'); // Headings (must be at start of line) text = text.replace(/^######\s+(.+)$/gm, '
$1
'); text = text.replace(/^#####\s+(.+)$/gm, '
$1
'); text = text.replace(/^####\s+(.+)$/gm, '

$1

'); text = text.replace(/^###\s+(.+)$/gm, '

$1

'); text = text.replace(/^##\s+(.+)$/gm, '

$1

'); text = text.replace(/^#\s+(.+)$/gm, function (_, title) { const id = title.replace(/\s+/g, '-').replace(/[^\w-]/g, '').toLowerCase(); return `

${title}

`; }); // Ordered lists text = text.replace(/^(?:\d+\.\s.*(?:\n|$))+/gm, function (match) { const items = match.trim().split('\n').map(l => l.replace(/^\d+\.\s+/, '').trim()); return '
    ' + items.map(i => `
  1. ${i}
  2. `).join('') + '
\n'; }); // Unordered lists text = text.replace(/^(?:[-*+]\s.*(?:\n|$))+/gm, function (match) { const items = match.trim().split('\n').map(l => l.replace(/^[-*+]\s+/, '').trim()); return '\n'; }); // Tables (pipe tables: header | separator | rows) text = text.replace(/^(\|.+\|)\n\|[-| :]+\|\n((?:\|.+\|\n?)*)/gm, function (_, header, rows) { const headers = header.split('|').map(c => c.trim()).filter(Boolean); const rowLines = rows.trim().split('\n').filter(l => l.trim()); const body = rowLines.map(r => { const cells = r.split('|').map(c => c.trim()).filter(Boolean); return '' + cells.map(c => `${c}`).join('') + ''; }).join(''); return '' + headers.map(h => ``).join('') + '' + body + '
${h}
\n'; }); // Restore code blocks text = text.replace(/\x00CODEBLOCK(\d+)\x00/g, (_, idx) => blocks[parseInt(idx)]); // Double line breaks → paragraph breaks (but not inside block elements) // Simple approach: wrap consecutive text lines in

tags // Skip elements that are already block-level const blockTags = [''); if (pText.trim()) { html += '

' + pText.trim() + '

\n'; } paraLines = []; } } for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line) { flushPara(); continue; } const isBlock = blockTags.some(tag => line.startsWith(tag)); if (isBlock) { flushPara(); html += line + '\n'; } else { paraLines.push(line); } } flushPara(); return html; } // ── Vault API helpers ───────────────────────────────────────────────── async function apiFetch(url) { const resp = await fetch(url); if (!resp.ok) { let msg = `HTTP ${resp.status}`; try { const body = await resp.json(); if (body.error) msg = body.error; } catch (_) {} throw new Error(msg); } return resp.json(); } // ── Tree Node Cache ─────────────────────────────────────────────────── // We cache directory contents so expanding/collapsing folders doesn't re-fetch. const treeCache = new Map(); async function getTree(dirPath) { const key = dirPath || ''; if (treeCache.has(key)) return treeCache.get(key); const params = dirPath ? `?path=${encodeURIComponent(dirPath)}` : ''; const data = await apiFetch(`${VAULT_API}/tree${params}`); treeCache.set(key, data); return data; } function invalidateTreeCache(path) { // If we update a path, clear its parent cache too const parts = path.split('/'); while (parts.length) { treeCache.delete(parts.join('/')); parts.pop(); } treeCache.delete(''); // root } // ── Tree Panel ──────────────────────────────────────────────────────── let treeLoadStack = []; // tracks which nodes are expanded function renderTree(dirPath, container, level) { level = level || 0; const padding = level * 18; getTree(dirPath).then(data => { const items = data.items || []; for (const item of items) { const node = document.createElement('div'); node.className = 'vault-tree-node'; node.style.paddingLeft = (padding + 4) + 'px'; if (item.type === 'folder') { const isExpanded = treeLoadStack.includes(dirPath + '/' + item.name); const icon = isExpanded ? '▼' : '▶'; node.innerHTML = `${icon} ${escapeHtml(item.name)}`; node.className += ' vault-tree-folder'; node._path = dirPath ? dirPath + '/' + item.name : item.name; node._expanded = isExpanded; if (isExpanded) { const childContainer = document.createElement('div'); childContainer.className = 'vault-tree-children'; node.appendChild(childContainer); renderTree(node._path, childContainer, level + 1); } node.addEventListener('click', e => { e.stopPropagation(); toggleFolder(node); }); } else if (item.name.endsWith('.md') || item.name.endsWith('.txt') || item.name.endsWith('.markdown')) { node.innerHTML = `📄 ${escapeHtml(item.name)}`; node.className += ' vault-tree-file'; node._path = dirPath ? dirPath + '/' + item.name : item.name; node.addEventListener('click', e => { e.stopPropagation(); loadFile(node._path); }); } else { node.innerHTML = `📄 ${escapeHtml(item.name)}`; node.className += ' vault-tree-file vault-tree-other'; } container.appendChild(node); } }).catch(err => { const errNode = document.createElement('div'); errNode.className = 'vault-tree-error'; errNode.textContent = '⚠️ ' + err.message; container.appendChild(errNode); }); } function toggleFolder(node) { const children = node.querySelector('.vault-tree-children'); const icon = node.querySelector('.tree-folder-icon'); if (children) { // Collapse node.removeChild(children); icon.textContent = '▶'; node._expanded = false; const idx = treeLoadStack.indexOf(node._path); if (idx > -1) treeLoadStack.splice(idx, 1); } else { // Expand icon.textContent = '▼'; node._expanded = true; treeLoadStack.push(node._path); const childContainer = document.createElement('div'); childContainer.className = 'vault-tree-children'; node.appendChild(childContainer); renderTree(node._path, childContainer, parseInt(node.style.paddingLeft) / 18 + 1); } } async function loadTree(rootPath) { const container = document.getElementById('vault-tree'); container.innerHTML = ''; // clear treeLoadStack = []; renderTree(rootPath, container, 0); } // ── Content Panel ───────────────────────────────────────────────────── async function loadFile(filePath) { const panel = document.getElementById('vault-content'); panel.innerHTML = '
Loading...
'; try { const data = await apiFetch(`${VAULT_API}/read?path=${encodeURIComponent(filePath)}`); const html = renderMarkdown(data.content); panel.innerHTML = `
${escapeHtml(data.path)}
${html}
`; // Wire up wiki-link clicks panel.querySelectorAll('.wiki-link').forEach(link => { link.addEventListener('click', e => { e.preventDefault(); const target = link.dataset.wikiLink; navigateToWikiPage(target); }); }); // Highlight current file in tree document.querySelectorAll('.vault-tree-file.active').forEach(el => el.classList.remove('active')); document.querySelectorAll('.vault-tree-file').forEach(el => { if (el._path === filePath) { el.classList.add('active'); el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } }); } catch (err) { panel.innerHTML = `
Error: ${escapeHtml(err.message)}
`; } } async function navigateToWikiPage(pageName) { // Look for a file named after the wiki link const encoded = encodeURIComponent(pageName); // Try exact filename first, then search const possiblePaths = [ pageName + '.md', 'wiki/' + pageName + '.md', pageName + '/' + pageName + '.md', ]; for (const p of possiblePaths) { try { await apiFetch(`${VAULT_API}/read?path=${encodeURIComponent(p)}`); loadFile(p); return; } catch (_) {} } // Fallback: search for it const data = await apiFetch(`${VAULT_API}/search?q=${encodeURIComponent(pageName)}`); if (data.results && data.results.length > 0) { loadFile(data.results[0].path); } else { const panel = document.getElementById('vault-content'); panel.innerHTML = `
Page not found: ${escapeHtml(pageName)}
`; } } // ── Search ──────────────────────────────────────────────────────────── let searchTimeout = null; function setupSearch() { const input = document.getElementById('vault-search-input'); const results = document.getElementById('vault-search-results'); input.addEventListener('input', () => { clearTimeout(searchTimeout); const q = input.value.trim(); if (q.length < 2) { results.hidden = true; return; } searchTimeout = setTimeout(() => performSearch(q), 300); }); // Close on Escape / click outside document.addEventListener('click', e => { if (!e.target.closest('.vault-search-box')) { results.hidden = true; } }); input.addEventListener('keydown', e => { if (e.key === 'Escape') results.hidden = true; if (e.key === 'Enter' && !results.hidden) { const first = results.querySelector('.vault-search-result-item'); if (first) { first.click(); results.hidden = true; } } }); } async function performSearch(q) { const results = document.getElementById('vault-search-results'); results.innerHTML = '
Searching...
'; results.hidden = false; try { const data = await apiFetch(`${VAULT_API}/search?q=${encodeURIComponent(q)}`); if (data.count === 0) { results.innerHTML = '
No results found
'; return; } let html = `
${data.count} result${data.count !== 1 ? 's' : ''}
`; for (const r of data.results.slice(0, 50)) { const displayPath = r.path.length > 80 ? '...' + r.path.slice(-77) : r.path; html += `
${escapeHtml(displayPath)}:${r.line}
${escapeHtml(r.snippet)}
`; } if (data.count > 50) { html += `
... and ${data.count - 50} more
`; } results.innerHTML = html; results.querySelectorAll('.vault-search-result-item').forEach(item => { item.addEventListener('click', () => { loadFile(item.dataset.path); results.hidden = true; document.getElementById('vault-search-input').value = ''; }); }); } catch (err) { results.innerHTML = `
Error: ${escapeHtml(err.message)}
`; } } // ── Init ────────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { // Only init vault when its panel is shown const vaultPanel = document.getElementById('vault'); const observer = new MutationObserver(() => { if (!vaultPanel.hidden && !vaultPanel.dataset.vaultLoaded) { vaultPanel.dataset.vaultLoaded = 'true'; initVault(); } }); observer.observe(vaultPanel, { attributes: true, attributeFilter: ['hidden'] }); // Also check initially if (!vaultPanel.hidden && !vaultPanel.dataset.vaultLoaded) { vaultPanel.dataset.vaultLoaded = 'true'; initVault(); } // Toggle tree sidebar on mobile document.getElementById('vault-toggle-tree').addEventListener('click', () => { document.getElementById('vault-tree').classList.toggle('vault-tree-visible'); }); }); function initVault() { setupSearch(); loadTree(''); // Close tree on file click in mobile view document.getElementById('vault-tree').addEventListener('click', e => { if (window.innerWidth <= 900 && e.target.closest('.vault-tree-file')) { document.getElementById('vault-tree').classList.remove('vault-tree-visible'); } }); }