added graph view!

This commit is contained in:
Gamosoft 2025-12-04 18:05:41 +01:00
parent 7faeff1799
commit 5f27fb724a
3 changed files with 456 additions and 31 deletions

View File

@ -1006,9 +1006,10 @@ async def search(q: str):
@api_router.get("/graph") @api_router.get("/graph")
async def 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: try:
import re import re
import urllib.parse
notes = get_all_notes(config['storage']['notes_dir']) notes = get_all_notes(config['storage']['notes_dir'])
nodes = [] nodes = []
edges = [] edges = []
@ -1044,8 +1045,9 @@ async def get_graph():
# Find wikilinks: [[target]] or [[target|display]] # Find wikilinks: [[target]] or [[target|display]]
wikilinks = re.findall(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]', content) wikilinks = re.findall(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]', content)
# Find standard markdown internal links: [text](path.md) # Find standard markdown internal links: [text](path) - any local path (not http/https)
markdown_links = re.findall(r'\[([^\]]+)\]\(([^\)]+\.md)\)', content) # Match links that don't start with http://, https://, mailto:, #, etc.
markdown_links = re.findall(r'\[([^\]]+)\]\((?!https?://|mailto:|#|data:)([^\)]+)\)', content)
# Process wikilinks # Process wikilinks
for target in wikilinks: for target in wikilinks:
@ -1079,12 +1081,50 @@ async def get_graph():
# Process markdown links # Process markdown links
for _, link_path in 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 target_path = None
# 1. Exact path match (with or without .md)
if link_path in note_paths: if link_path in note_paths:
target_path = link_path target_path = link_path if link_path.endswith('.md') else link_path + '.md'
elif link_path.lower() in note_paths_lower: elif link_path_with_md in note_paths:
target_path = note_paths_lower[link_path.lower()] 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']: if target_path and target_path != note['path']:
edges.append({ edges.append({

View File

@ -42,6 +42,12 @@ function noteApp() {
noteContent: '', noteContent: '',
viewMode: 'split', // 'edit', 'split', 'preview' viewMode: 'split', // 'edit', 'split', 'preview'
searchQuery: '', searchQuery: '',
// Graph state (separate overlay, doesn't affect viewMode)
showGraph: false,
graphInstance: null,
graphLoaded: false,
graphData: null,
searchResults: [], searchResults: [],
currentSearchHighlight: '', // Track current highlighted search term currentSearchHighlight: '', // Track current highlighted search term
currentMatchIndex: 0, // Current match being viewed currentMatchIndex: 0, // Current match being viewed
@ -549,6 +555,11 @@ function noteApp() {
this.renderMermaid(); this.renderMermaid();
}, 100); }, 100);
} }
// Refresh graph if visible (longer delay to ensure CSS is applied)
if (this.showGraph) {
setTimeout(() => this.initGraph(), 300);
}
} catch (error) { } catch (error) {
console.error('Failed to load theme:', error); console.error('Failed to load theme:', error);
} }
@ -1055,9 +1066,7 @@ function noteApp() {
const icon = isImage ? '🖼️' : ''; const icon = isImage ? '🖼️' : '';
// Click handler // Click handler
const clickHandler = isImage const clickHandler = `openItem('${note.path.replace(/'/g, "\\'")}', '${note.type}')`;
? `viewImage('${note.path.replace(/'/g, "\\'")}')`
: `loadNote('${note.path.replace(/'/g, "\\'")}')`;
// Delete handler // Delete handler
const deleteHandler = isImage 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 // View an image in the main pane
viewImage(imagePath, updateHistory = true) { viewImage(imagePath, updateHistory = true) {
this.showGraph = false; // Ensure graph is closed
this.currentNote = ''; this.currentNote = '';
this.currentNoteName = ''; this.currentNoteName = '';
this.noteContent = ''; this.noteContent = '';
@ -3753,6 +3773,7 @@ function noteApp() {
// Homepage folder navigation methods // Homepage folder navigation methods
goToHomepageFolder(folderPath) { goToHomepageFolder(folderPath) {
this.showGraph = false; // Close graph when navigating
this.selectedHomepageFolder = folderPath || ''; this.selectedHomepageFolder = folderPath || '';
// Clear editor state to show landing page // Clear editor state to show landing page
@ -3774,6 +3795,7 @@ function noteApp() {
// Navigate to homepage root and clear all editor state // Navigate to homepage root and clear all editor state
goHome() { goHome() {
this.showGraph = false; // Close graph when going home
this.selectedHomepageFolder = ''; this.selectedHomepageFolder = '';
this.currentNote = ''; this.currentNote = '';
this.currentNoteName = ''; this.currentNoteName = '';
@ -3790,6 +3812,291 @@ function noteApp() {
}; };
window.history.pushState({ homepageFolder: '' }, '', '/'); 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 = `
<div class="graph-legend-item">
<span class="graph-legend-dot" style="background: ${wikiColor};"></span>
<span style="color: ${textColor};">Wikilinks</span>
</div>
<div class="graph-legend-item">
<span class="graph-legend-dot" style="background: ${mdColor};"></span>
<span style="color: ${textColor};">Markdown links</span>
</div>
<div style="margin-top: 8px; font-size: 10px; color: ${textColor}; opacity: 0.7;">
Click: select Double-click: open
</div>
`;
container.appendChild(legend);
},
// Refresh graph when theme changes
refreshGraph() {
if (this.viewMode === 'graph' && this.graphInstance) {
this.initGraph();
}
} }
} }
} }

View File

@ -64,6 +64,9 @@
window.mermaid = mermaid; window.mermaid = mermaid;
</script> </script>
<!-- vis-network for graph visualization (v9.1.9) -->
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
<!-- Theme styles will be loaded dynamically --> <!-- Theme styles will be loaded dynamically -->
<link rel="stylesheet" id="theme-stylesheet" href=""> <link rel="stylesheet" id="theme-stylesheet" href="">
@ -308,6 +311,42 @@
border-bottom-style: solid; 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)) */ /* 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]) { .markdown-preview a:not([href^="http"]):not([href^="https"]):not([href^="//"]):not([href^="mailto:"]):not([data-wikilink]) {
color: var(--accent-primary); color: var(--accent-primary);
@ -948,7 +987,7 @@
</div> </div>
<template x-for="note in searchResults" :key="note.path"> <template x-for="note in searchResults" :key="note.path">
<div <div
@click="loadNote(note.path, true, searchQuery)" @click="openItem(note.path, note.type, searchQuery)"
class="px-3 py-2 mb-1 text-sm rounded cursor-pointer" class="px-3 py-2 mb-1 text-sm rounded cursor-pointer"
:style="currentNote === note.path ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);'" :style="currentNote === note.path ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);'"
onmouseover="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='var(--bg-hover)'" onmouseover="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='var(--bg-hover)'"
@ -1061,7 +1100,7 @@
draggable="true" draggable="true"
@dragstart="onNoteDragStart(note.path, $event)" @dragstart="onNoteDragStart(note.path, $event)"
@dragend="onNoteDragEnd()" @dragend="onNoteDragEnd()"
@click="note.type === 'image' ? viewImage(note.path) : loadNote(note.path)" @click="openItem(note.path, note.type)"
class="note-item px-3 py-2 mb-1 text-sm rounded relative border-2 border-transparent" class="note-item px-3 py-2 mb-1 text-sm rounded 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;')" :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)'" @mouseover="if(currentNote !== note.path && currentImage !== note.path) $el.style.backgroundColor='var(--bg-hover)'"
@ -1096,11 +1135,12 @@
<!-- Footer --> <!-- Footer -->
<div class="flex-shrink-0 p-3 border-t" style="border-color: var(--border-primary);"> <div class="flex-shrink-0 p-3 border-t" style="border-color: var(--border-primary);">
<!-- Theme Selector (compact) --> <!-- Theme Selector and Graph Button -->
<div class="flex gap-2 mb-2">
<select <select
x-model="currentTheme" x-model="currentTheme"
@change="setTheme(currentTheme)" @change="setTheme(currentTheme)"
class="w-full px-2 py-1.5 text-xs rounded mb-2 focus:outline-none focus:ring-2 focus:ring-opacity-50" class="flex-1 px-2 py-1.5 text-xs rounded focus:outline-none focus:ring-2 focus:ring-opacity-50"
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-primary); cursor: pointer;" style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-primary); cursor: pointer;"
title="Select theme" title="Select theme"
> >
@ -1108,6 +1148,15 @@
<option :value="theme.id" x-text="theme.name"></option> <option :value="theme.id" x-text="theme.name"></option>
</template> </template>
</select> </select>
<button
@click="showGraph = true; initGraph()"
class="px-2 py-1.5 text-xs rounded transition-colors"
:style="showGraph ? 'background-color: var(--accent-primary); color: white;' : 'background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-primary);'"
title="View note connections graph"
>
🔗
</button>
</div>
<!-- Stats --> <!-- Stats -->
<div class="text-xs text-center" style="color: var(--text-tertiary);"> <div class="text-xs text-center" style="color: var(--text-tertiary);">
@ -1139,7 +1188,36 @@
></div> ></div>
<!-- Main Content Area --> <!-- Main Content Area -->
<div class="flex-1 flex flex-col overflow-hidden"> <div class="flex-1 flex flex-col overflow-hidden relative">
<!-- Graph View Overlay (works from homepage or editor) -->
<div
x-show="showGraph"
id="graph-overlay"
class="absolute inset-0"
style="background-color: var(--bg-primary); z-index: 50;"
>
<!-- Graph renders here via vis-network -->
<div x-show="!graphLoaded" class="flex items-center justify-center h-full">
<div class="text-center" style="color: var(--text-secondary);">
<svg class="animate-spin h-8 w-8 mx-auto mb-3" style="color: var(--accent-primary);" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p>Loading graph...</p>
</div>
</div>
<!-- Close button -->
<button
@click="showGraph = false"
class="absolute top-4 left-4 px-3 py-2 text-sm font-medium rounded-lg transition-colors z-10"
style="background-color: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-primary);"
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
onmouseout="this.style.backgroundColor='var(--bg-secondary)'"
>
← Back
</button>
</div>
<template x-if="!currentNote && !currentImage"> <template x-if="!currentNote && !currentImage">
<!-- Notes Homepage --> <!-- Notes Homepage -->
@ -1309,7 +1387,7 @@
<!-- Notes --> <!-- Notes -->
<template x-for="note in homepageNotes().slice(0, HOMEPAGE_MAX_NOTES)" :key="note.path"> <template x-for="note in homepageNotes().slice(0, HOMEPAGE_MAX_NOTES)" :key="note.path">
<div <div
@click="note.path.match(/\.(png|jpg|jpeg|gif|webp)$/i) ? (currentImage = note.path, currentNote = '') : loadNote(note.path)" @click="openItem(note.path, note.type)"
class="p-4 rounded-lg border-2 cursor-pointer transition-all hover:shadow-lg" class="p-4 rounded-lg border-2 cursor-pointer transition-all hover:shadow-lg"
style="background-color: var(--bg-secondary); border-color: var(--border-primary); min-height: 140px; display: flex; flex-direction: column; position: relative;" style="background-color: var(--bg-secondary); border-color: var(--border-primary); min-height: 140px; display: flex; flex-direction: column; position: relative;"
onmouseover="this.style.borderColor='var(--accent-primary)'; this.style.transform='translateY(-2px)'; this.querySelector('.card-delete-btn').style.opacity='1';" onmouseover="this.style.borderColor='var(--accent-primary)'; this.style.transform='translateY(-2px)'; this.querySelector('.card-delete-btn').style.opacity='1';"
@ -1475,11 +1553,11 @@
</div> </div>
</div> </div>
<!-- Editor/Preview --> <!-- Editor/Preview/Graph -->
<div class="flex-1 flex relative" style="min-height: 0;"> <div class="flex-1 flex relative" style="min-height: 0;">
<!-- Editor --> <!-- Editor -->
<div <div
x-show="viewMode === 'edit' || viewMode === 'split'" x-show="!currentImage && (viewMode === 'edit' || viewMode === 'split')"
class="flex flex-col" class="flex flex-col"
style="min-height: 0;" style="min-height: 0;"
:style="viewMode === 'split' ? `width: ${editorWidth}%;` : 'width: 100%;'" :style="viewMode === 'split' ? `width: ${editorWidth}%;` : 'width: 100%;'"
@ -1501,7 +1579,7 @@
<!-- Split view resize handle --> <!-- Split view resize handle -->
<div <div
x-show="viewMode === 'split'" x-show="!currentImage && viewMode === 'split'"
@mousedown="startSplitResize($event)" @mousedown="startSplitResize($event)"
class="split-resize-handle" class="split-resize-handle"
style="width: 6px; cursor: col-resize; background: linear-gradient(90deg, transparent 0%, var(--border-secondary) 50%, transparent 100%); transition: all 0.2s; position: relative; z-index: 10; opacity: 0.5;" style="width: 6px; cursor: col-resize; background: linear-gradient(90deg, transparent 0%, var(--border-secondary) 50%, transparent 100%); transition: all 0.2s; position: relative; z-index: 10; opacity: 0.5;"