diff --git a/README.md b/README.md index 1739673..bb6ddf1 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ NoteDiscovery is a **lightweight, self-hosted note-taking application** that put - 📂 **Simple Storage** - Plain markdown files in folders - 🧮 **Math Support** - LaTeX/MathJax for beautiful equations - 📄 **HTML Export** - Share notes as standalone HTML files +- 🕸️ **Graph View** - Interactive visualization of connected notes ## 🚀 Quick Start 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/backend/utils.py b/backend/utils.py index ba3a118..4e8e2b4 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -273,8 +273,9 @@ def search_notes(notes_dir: str, query: str) -> List[Dict]: """ Full-text search through note contents only. Does NOT search in file names, folder names, or paths - only note content. - Uses character-based context extraction for better snippet quality. + Uses character-based context extraction with highlighted matches. """ + from html import escape results = [] notes_path = Path(notes_dir) @@ -292,16 +293,19 @@ def search_notes(notes_dir: str, query: str) -> List[Dict]: for match in matches[:3]: # Limit to 3 matches per file start_index = match.start() end_index = match.end() + matched_text = match.group() # Preserve original case - # Create slice window: ±30 characters around match - context_start = max(0, start_index - 30) - context_end = min(len(content), end_index + 30) + # Create slice window: ±15 characters around match + context_start = max(0, start_index - 15) + context_end = min(len(content), end_index + 15) - # Extract snippet - snippet = content[context_start:context_end] + # Extract and clean parts (newlines → spaces) + before = escape(content[context_start:start_index].replace('\n', ' ')) + after = escape(content[end_index:context_end].replace('\n', ' ')) + matched_clean = escape(matched_text.replace('\n', ' ')) - # Replace all newlines with spaces to ensure UI doesn't break - snippet = snippet.replace('\n', ' ') + # Build snippet with highlight (styled via CSS) + snippet = f'{before}{matched_clean}{after}' # Add ellipsis if truncated at start if context_start > 0: diff --git a/docs/index.html b/docs/index.html index b23d273..bf3f16f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -7,7 +7,7 @@ NoteDiscovery - Your Self-Hosted Knowledge Base - + @@ -586,6 +586,12 @@

Link notes with [[double brackets]] Obsidian-style. Broken links shown dimmed.

+
+
🕸️
+

Graph View

+

Interactive visualization of connected notes. Drag to explore relationships.

+
+
📂

Simple Storage

@@ -664,6 +670,14 @@
+
+ 🕸️ +
+ Graph View + Visualize note connections +
+
+
🖼️
@@ -759,7 +773,7 @@ "url": "https://www.notediscovery.com", "applicationCategory": "ProductivityApplication", "operatingSystem": "Linux, Windows, macOS", - "featureList": "Markdown editor, Wikilinks, LaTeX math equations, Mermaid diagrams, Code syntax highlighting, Dark mode, Plugin system, Internal links, Properties panel, HTML export, Full-text search, Self-hosted, Obsidian, Evernote, Notion, Onenote, Second brain", + "featureList": "Markdown editor, Wikilinks, Graph view, LaTeX math equations, Mermaid diagrams, Code syntax highlighting, Dark mode, Plugin system, Internal links, Properties panel, HTML export, Full-text search, Self-hosted, Obsidian, Evernote, Notion, Onenote, Second brain", "offers": { "@type": "Offer", "price": "0", diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md index db1fb86..78887d6 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -28,6 +28,12 @@ ## 🔗 Linking & Discovery +### Graph View +- **Interactive graph** - Visualize all your notes and their connections +- **Navigate with mouse** - Drag to pan, scroll to zoom, double-click nodes to open notes +- **Multiple link types** - See wikilinks and markdown links distinguished by color +- **Theme-aware** - Graph colors adapt to your current theme + ### Internal Links - **Wikilinks** - `[[Note Name]]` Obsidian-style syntax for quick linking - **Wikilinks with display text** - `[[Note Name|Click here]]` to customize link text diff --git a/frontend/app.js b/frontend/app.js index 4212642..4beb7f3 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); } @@ -971,7 +982,7 @@ function noteApp() { @dragenter.prevent="dragOverFolder = '${folder.path.replace(/'/g, "\\'")}'" @dragleave="dragOverFolder = null" @drop.stop="onFolderDrop('${folder.path.replace(/'/g, "\\'")}' )" - class="folder-item px-3 py-3 mb-1 text-sm rounded transition-all relative" + class="folder-item px-2 py-1 text-sm relative" style="color: var(--text-primary); cursor: pointer;" :class="{ 'border-2 border-dashed bg-accent-light': (draggedNote || draggedFolder) && dragOverFolder === '${folder.path.replace(/'/g, "\\'")}', @@ -1029,7 +1040,7 @@ function noteApp() { // If expanded, render folder contents (child folders + notes) if (isExpanded) { - html += `
`; + html += `
`; // First, render child folders (if any) if (folder.children && Object.keys(folder.children).length > 0) { @@ -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 @@ -1071,7 +1080,7 @@ function noteApp() { @dragstart="onNoteDragStart('${note.path.replace(/'/g, "\\'")}', $event)" @dragend="onNoteDragEnd()" @click="${clickHandler}" - class="note-item px-3 py-2 mb-1 text-sm rounded relative border-2 border-transparent" + class="note-item px-2 py-1 text-sm relative border-2 border-transparent" style="${isCurrent ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);'} ${isImage ? 'opacity: 0.85;' : ''} cursor: pointer;" @mouseover="if('${note.path}' !== currentNote && '${note.path}' !== currentImage) $el.style.backgroundColor='var(--bg-hover)'" @mouseout="if('${note.path}' !== currentNote && '${note.path}' !== currentImage) $el.style.backgroundColor='transparent'" @@ -1160,7 +1169,7 @@ function noteApp() { const sidebar = document.querySelector('.flex-1.overflow-y-auto.custom-scrollbar'); if (!sidebar) return; - const noteElements = sidebar.querySelectorAll('[class*="px-3 py-2 mb-1"]'); + const noteElements = sidebar.querySelectorAll('.note-item'); let targetElement = null; const noteName = notePath.split('/').pop().replace('.md', ''); @@ -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}`); } } } @@ -1895,18 +1915,10 @@ function noteApp() { mark.className = 'search-highlight'; mark.setAttribute('data-match-index', matchIndex); mark.textContent = text.substring(index, index + searchTerm.length); - mark.style.padding = '2px 4px'; - mark.style.borderRadius = '3px'; - mark.style.transition = 'all 0.2s'; - // Style first match as active, others as inactive + // First match is active (styled via CSS) if (matchIndex === 0) { - mark.style.backgroundColor = 'var(--accent-primary)'; - mark.style.color = 'white'; mark.classList.add('active-match'); - } else { - mark.style.backgroundColor = 'rgba(255, 193, 7, 0.4)'; - mark.style.color = 'var(--text-primary)'; } fragment.appendChild(mark); @@ -1961,17 +1973,9 @@ function noteApp() { const allMatches = preview.querySelectorAll('mark.search-highlight'); if (index < 0 || index >= allMatches.length) return; - // Update styling - make current match prominent + // Update styling - make current match prominent (via CSS class) allMatches.forEach((mark, i) => { - if (i === index) { - mark.style.backgroundColor = 'var(--accent-primary)'; - mark.style.color = 'white'; - mark.classList.add('active-match'); - } else { - mark.style.backgroundColor = 'rgba(255, 193, 7, 0.4)'; - mark.style.color = 'var(--text-primary)'; - mark.classList.remove('active-match'); - } + mark.classList.toggle('active-match', i === index); }); // Scroll to the match @@ -3753,6 +3757,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 +3779,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 +3796,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 08af448..77e9a39 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -64,6 +64,9 @@ window.mermaid = mermaid; + + + @@ -289,6 +292,19 @@ .markdown-preview a[data-wikilink].wikilink-broken:hover { opacity: 1; } + + /* Search highlight base style (used in note preview and search results) */ + .search-highlight { + padding: 2px 4px; + border-radius: 3px; + background-color: rgba(255, 193, 7, 0.4); + color: var(--text-primary); + transition: all 0.2s; + } + .search-highlight.active-match { + background-color: var(--accent-primary); + color: white; + } /* Note Properties Panel */ .note-properties { @@ -308,6 +324,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); @@ -391,10 +443,10 @@ cursor: copy !important; } - /* Folder tree styling */ + /* Folder tree styling - compact Obsidian-like */ .folder-item { user-select: none; - transition: all 0.2s ease; + border-radius: 4px; } .folder-item:hover { @@ -406,8 +458,11 @@ } .note-item { - padding-left: 1.5rem; - transition: all 0.2s ease; + border-radius: 4px; + } + + .note-item:hover { + background-color: var(--bg-hover); } /* Drag and drop improvements */ @@ -424,7 +479,7 @@ /* Smooth transitions for drag states */ .folder-item, .note-item { - transition: opacity 0.2s ease, transform 0.15s ease, background-color 0.15s ease, border-color 0.15s ease; + transition: background-color 0.15s ease, border-color 0.15s ease; } /* Better drop zone visibility */ @@ -794,25 +849,25 @@
-
+
- +
@@ -923,16 +978,16 @@
-
+
@@ -948,15 +1003,15 @@
@@ -1062,8 +1117,8 @@ draggable="true" @dragstart="onNoteDragStart(note.path, $event)" @dragend="onNoteDragEnd()" - @click="note.type === 'image' ? viewImage(note.path) : loadNote(note.path)" - class="note-item px-3 py-2 mb-1 text-sm rounded relative border-2 border-transparent" + @click="openItem(note.path, note.type)" + class="note-item px-2 py-1 text-sm relative border-2 border-transparent" :style="((note.type === 'image' ? currentImage === note.path : currentNote === note.path) ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);') + (note.type === 'image' ? ' opacity: 0.85;' : '') + (draggedNote || draggedFolder ? ' cursor: grabbing;' : ' cursor: pointer;')" @mouseover="if(currentNote !== note.path && currentImage !== note.path) $el.style.backgroundColor='var(--bg-hover)'" @mouseout="if(currentNote !== note.path && currentImage !== note.path) $el.style.backgroundColor='transparent'" @@ -1097,18 +1152,28 @@
- - + +
+ + +
@@ -1140,7 +1205,36 @@ >
-
+
+ + +
+ +
+
+ + + + +

Loading graph...

+
+
+ + +