From 5f27fb724a4c2011c23a81a847fc03a03b6f881c Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Thu, 4 Dec 2025 18:05:41 +0100 Subject: [PATCH] added graph view! --- backend/main.py | 54 +++++++- frontend/app.js | 317 +++++++++++++++++++++++++++++++++++++++++++- frontend/index.html | 116 +++++++++++++--- 3 files changed, 456 insertions(+), 31 deletions(-) diff --git a/backend/main.py b/backend/main.py index 3c88b5d..de8ecdd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1006,9 +1006,10 @@ async def search(q: str): @api_router.get("/graph") async def get_graph(): - """Get graph data for note visualization with wikilink detection""" + """Get graph data for note visualization with wikilink and markdown link detection""" try: import re + import urllib.parse notes = get_all_notes(config['storage']['notes_dir']) nodes = [] edges = [] @@ -1044,8 +1045,9 @@ async def get_graph(): # Find wikilinks: [[target]] or [[target|display]] wikilinks = re.findall(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]', content) - # Find standard markdown internal links: [text](path.md) - markdown_links = re.findall(r'\[([^\]]+)\]\(([^\)]+\.md)\)', content) + # Find standard markdown internal links: [text](path) - any local path (not http/https) + # Match links that don't start with http://, https://, mailto:, #, etc. + markdown_links = re.findall(r'\[([^\]]+)\]\((?!https?://|mailto:|#|data:)([^\)]+)\)', content) # Process wikilinks for target in wikilinks: @@ -1079,12 +1081,50 @@ async def get_graph(): # Process markdown links for _, link_path in markdown_links: - # Try exact match first, then case-insensitive + # Skip anchor-only links and external protocols + if not link_path or link_path.startswith('#'): + continue + + # Remove anchor part if present (e.g., "note.md#section" -> "note.md") + link_path = link_path.split('#')[0] + if not link_path: + continue + + # Normalize path: remove ./ prefix, handle URL encoding + link_path = urllib.parse.unquote(link_path) + if link_path.startswith('./'): + link_path = link_path[2:] + + # Add .md extension if not present and doesn't have other extension + link_path_with_md = link_path if link_path.endswith('.md') else link_path + '.md' + link_path_lower = link_path.lower() + link_path_with_md_lower = link_path_with_md.lower() + + # Try to match target to an existing note target_path = None + + # 1. Exact path match (with or without .md) if link_path in note_paths: - target_path = link_path - elif link_path.lower() in note_paths_lower: - target_path = note_paths_lower[link_path.lower()] + target_path = link_path if link_path.endswith('.md') else link_path + '.md' + elif link_path_with_md in note_paths: + target_path = link_path_with_md + # 2. Case-insensitive path match + elif link_path_lower in note_paths_lower: + target_path = note_paths_lower[link_path_lower] + elif link_path_with_md_lower in note_paths_lower: + target_path = note_paths_lower[link_path_with_md_lower] + # 3. Try matching by filename only (for relative links) + else: + # Extract just the filename + filename = link_path.split('/')[-1] + filename_lower = filename.lower() + filename_with_md = filename if filename.endswith('.md') else filename + '.md' + filename_with_md_lower = filename_with_md.lower() + + if filename_lower in note_names: + target_path = note_names[filename_lower] + elif filename_with_md_lower in note_names: + target_path = note_names[filename_with_md_lower] if target_path and target_path != note['path']: edges.append({ diff --git a/frontend/app.js b/frontend/app.js index 4212642..867eb74 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -42,6 +42,12 @@ function noteApp() { noteContent: '', viewMode: 'split', // 'edit', 'split', 'preview' searchQuery: '', + + // Graph state (separate overlay, doesn't affect viewMode) + showGraph: false, + graphInstance: null, + graphLoaded: false, + graphData: null, searchResults: [], currentSearchHighlight: '', // Track current highlighted search term currentMatchIndex: 0, // Current match being viewed @@ -549,6 +555,11 @@ function noteApp() { this.renderMermaid(); }, 100); } + + // Refresh graph if visible (longer delay to ensure CSS is applied) + if (this.showGraph) { + setTimeout(() => this.initGraph(), 300); + } } catch (error) { console.error('Failed to load theme:', error); } @@ -1055,9 +1066,7 @@ function noteApp() { const icon = isImage ? '🖼️' : ''; // Click handler - const clickHandler = isImage - ? `viewImage('${note.path.replace(/'/g, "\\'")}')` - : `loadNote('${note.path.replace(/'/g, "\\'")}')`; + const clickHandler = `openItem('${note.path.replace(/'/g, "\\'")}', '${note.type}')`; // Delete handler const deleteHandler = isImage @@ -1431,8 +1440,19 @@ function noteApp() { } }, + // Open a note or image (unified handler for sidebar/homepage clicks) + openItem(path, type = 'note', searchHighlight = '') { + this.showGraph = false; + if (type === 'image' || path.match(/\.(png|jpg|jpeg|gif|webp)$/i)) { + this.viewImage(path); + } else { + this.loadNote(path, true, searchHighlight); + } + }, + // View an image in the main pane viewImage(imagePath, updateHistory = true) { + this.showGraph = false; // Ensure graph is closed this.currentNote = ''; this.currentNoteName = ''; this.noteContent = ''; @@ -1526,8 +1546,8 @@ function noteApp() { ); if (noteByPathCI) { this.loadNote(noteByPathCI.path); - } else { - alert(`Note not found: ${notePath}`); + } else { + alert(`Note not found: ${notePath}`); } } } @@ -3753,6 +3773,7 @@ function noteApp() { // Homepage folder navigation methods goToHomepageFolder(folderPath) { + this.showGraph = false; // Close graph when navigating this.selectedHomepageFolder = folderPath || ''; // Clear editor state to show landing page @@ -3774,6 +3795,7 @@ function noteApp() { // Navigate to homepage root and clear all editor state goHome() { + this.showGraph = false; // Close graph when going home this.selectedHomepageFolder = ''; this.currentNote = ''; this.currentNoteName = ''; @@ -3790,6 +3812,291 @@ function noteApp() { }; window.history.pushState({ homepageFolder: '' }, '', '/'); + }, + + // ==================== GRAPH VIEW ==================== + + // Initialize the graph visualization + async initGraph() { + // Check if vis is loaded + if (typeof vis === 'undefined') { + console.error('vis-network library not loaded'); + return; + } + + this.graphLoaded = false; + + try { + // Fetch graph data from API + const response = await fetch('/api/graph'); + if (!response.ok) throw new Error('Failed to fetch graph data'); + const data = await response.json(); + this.graphData = data; + + // Get container + const container = document.getElementById('graph-overlay'); + if (!container) return; + + // Get theme colors (force reflow to ensure CSS is applied) + document.body.offsetHeight; // Force reflow + const style = getComputedStyle(document.documentElement); + + // Helper to get CSS variable with fallback + const getCssVar = (name, fallback) => { + const value = style.getPropertyValue(name).trim(); + return value || fallback; + }; + + const accentPrimary = getCssVar('--accent-primary', '#7c3aed'); + const accentSecondary = getCssVar('--accent-secondary', '#a78bfa'); + const textPrimary = getCssVar('--text-primary', '#111827'); + const textSecondary = getCssVar('--text-secondary', '#6b7280'); + const bgPrimary = getCssVar('--bg-primary', '#ffffff'); + const bgSecondary = getCssVar('--bg-secondary', '#f3f4f6'); + const borderColor = getCssVar('--border-primary', '#e5e7eb'); + + // Prepare nodes with styling - all nodes same base color + const nodes = new vis.DataSet(data.nodes.map(n => ({ + id: n.id, + label: n.label, + title: n.id, // Tooltip shows full path + color: { + background: accentPrimary, + border: accentPrimary, + highlight: { + background: accentPrimary, + border: textPrimary // Darker border when selected + }, + hover: { + background: accentSecondary, + border: accentPrimary + } + }, + font: { + color: textPrimary, + size: 12, + face: 'system-ui, -apple-system, sans-serif' + }, + borderWidth: this.currentNote === n.id ? 4 : 2, + chosen: { + node: (values) => { + values.size = 22; + values.borderWidth = 4; + values.borderColor = textPrimary; + } + } + }))); + + // Prepare edges with styling based on type + const edges = new vis.DataSet(data.edges.map((e, i) => ({ + id: i, + from: e.source, + to: e.target, + color: { + color: e.type === 'wikilink' ? accentPrimary : borderColor, + highlight: accentPrimary, + hover: accentSecondary, + opacity: 0.8 + }, + width: e.type === 'wikilink' ? 2 : 1, + smooth: { + type: 'continuous', + roundness: 0.5 + }, + chosen: { + edge: (values) => { + values.width = 3; + values.color = accentPrimary; + } + } + }))); + + // Network options + const options = { + nodes: { + shape: 'dot', + size: 16, + borderWidth: 2, + shadow: { + enabled: true, + color: 'rgba(0,0,0,0.1)', + size: 5, + x: 2, + y: 2 + } + }, + edges: { + arrows: { + to: { + enabled: true, + scaleFactor: 0.5, + type: 'arrow' + } + } + }, + physics: { + enabled: true, + solver: 'forceAtlas2Based', + forceAtlas2Based: { + gravitationalConstant: -50, + centralGravity: 0.01, + springLength: 100, + springConstant: 0.08, + damping: 0.4, + avoidOverlap: 0.5 + }, + stabilization: { + enabled: true, + iterations: 200, + updateInterval: 25 + } + }, + interaction: { + hover: true, + tooltipDelay: 200, + navigationButtons: false, // Using custom buttons instead + keyboard: { + enabled: true, + bindToWindow: false + }, + zoomView: true, + dragView: true + }, + layout: { + improvedLayout: true, + randomSeed: 42 + } + }; + + // Destroy existing instance if any + if (this.graphInstance) { + this.graphInstance.destroy(); + this.graphInstance = null; + } + + // Clear container to ensure clean state + const graphCanvas = container.querySelector('canvas'); + if (graphCanvas) graphCanvas.remove(); + const visElements = container.querySelectorAll('.vis-network, .vis-navigation'); + visElements.forEach(el => el.remove()); + + // Create the network + this.graphInstance = new vis.Network(container, { nodes, edges }, options); + + // Store reference for callbacks + const graphRef = this.graphInstance; + const currentNoteRef = this.currentNote; + + // Wait for stabilization + this.graphInstance.once('stabilizationIterationsDone', () => { + graphRef.setOptions({ physics: { enabled: false } }); + this.graphLoaded = true; + + // Focus and select current note if one is loaded + if (currentNoteRef) { + setTimeout(() => { + try { + if (graphRef && this.showGraph) { + const nodeIds = graphRef.body.data.nodes.getIds(); + if (nodeIds.includes(currentNoteRef)) { + // Focus on the node + graphRef.focus(currentNoteRef, { + scale: 1.2, + animation: { + duration: 500, + easingFunction: 'easeInOutQuad' + } + }); + // Select the node to highlight it + graphRef.selectNodes([currentNoteRef]); + } + } + } catch (e) { + // Ignore - graph may have been destroyed + } + }, 150); + } + }); + + // Click event - open note + this.graphInstance.on('click', (params) => { + if (params.nodes.length > 0) { + const noteId = params.nodes[0]; + this.loadNote(noteId); + // Node is already selected by vis-network on click, no need to call selectNodes + } + }); + + // Double-click event - open note and close graph + this.graphInstance.on('doubleClick', (params) => { + if (params.nodes.length > 0) { + const noteId = params.nodes[0]; + // Close graph and load note + this.showGraph = false; + this.loadNote(noteId); + } + }); + + // Hover event - highlight connections + this.graphInstance.on('hoverNode', (params) => { + const nodeId = params.node; + const connectedNodes = this.graphInstance.getConnectedNodes(nodeId); + const connectedEdges = this.graphInstance.getConnectedEdges(nodeId); + + // Dim all nodes except hovered and connected + const allNodes = nodes.getIds(); + const updates = allNodes.map(id => ({ + id, + opacity: (id === nodeId || connectedNodes.includes(id)) ? 1 : 0.2 + })); + nodes.update(updates); + }); + + this.graphInstance.on('blurNode', () => { + // Reset all nodes to full opacity + const allNodes = nodes.getIds(); + const updates = allNodes.map(id => ({ id, opacity: 1 })); + nodes.update(updates); + }); + + // Add legend to container + this.addGraphLegend(container, accentPrimary, borderColor, textSecondary); + + } catch (error) { + console.error('Failed to initialize graph:', error); + this.graphLoaded = true; // Stop loading indicator + } + }, + + // Add legend to graph container + addGraphLegend(container, wikiColor, mdColor, textColor) { + // Remove existing legend if any + const existingLegend = container.querySelector('.graph-legend'); + if (existingLegend) existingLegend.remove(); + + const legend = document.createElement('div'); + legend.className = 'graph-legend'; + legend.innerHTML = ` +
+ + Wikilinks +
+
+ + Markdown links +
+
+ Click: select • Double-click: open +
+ `; + container.appendChild(legend); + }, + + // Refresh graph when theme changes + refreshGraph() { + if (this.viewMode === 'graph' && this.graphInstance) { + this.initGraph(); + } } } } diff --git a/frontend/index.html b/frontend/index.html index 503a999..e5c2df7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -64,6 +64,9 @@ window.mermaid = mermaid; + + + @@ -308,6 +311,42 @@ border-bottom-style: solid; } + /* Graph View */ + #graph-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + } + #graph-overlay .vis-network { + outline: none; + width: 100% !important; + height: 100% !important; + } + .graph-legend { + position: absolute; + top: 10px; + right: 10px; + padding: 8px 12px; + border-radius: 8px; + font-size: 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + z-index: 10; + } + .graph-legend-item { + display: flex; + align-items: center; + gap: 6px; + margin: 4px 0; + } + .graph-legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; + } + /* Standard internal links (non-wikilink, e.g. [text](path)) */ .markdown-preview a:not([href^="http"]):not([href^="https"]):not([href^="//"]):not([href^="mailto:"]):not([data-wikilink]) { color: var(--accent-primary); @@ -948,7 +987,7 @@