diff --git a/backend/main.py b/backend/main.py index eb63054..3c88b5d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1006,24 +1006,103 @@ async def search(q: str): @api_router.get("/graph") async def get_graph(): - """Get graph data for note visualization (currently only returns nodes, link detection not implemented)""" + """Get graph data for note visualization with wikilink detection""" try: + import re notes = get_all_notes(config['storage']['notes_dir']) nodes = [] edges = [] - # Build graph structure - only nodes for now + # Build set of valid note names/paths for matching + note_paths = set() + note_paths_lower = {} # Map lowercase path -> actual path for case-insensitive matching + note_names = {} # Map name -> path for quick lookup + for note in notes: - if note.get('type') == 'note': # Only include actual notes + if note.get('type') == 'note': + note_paths.add(note['path']) + note_paths.add(note['path'].replace('.md', '')) + # Store lowercase path -> actual path mapping for case-insensitive matching + note_paths_lower[note['path'].lower()] = note['path'] + note_paths_lower[note['path'].replace('.md', '').lower()] = note['path'] + # Store name -> path mapping (without extension) + name = note['name'].replace('.md', '') + note_names[name.lower()] = note['path'] + note_names[note['name'].lower()] = note['path'] + + # Build graph structure with link detection + for note in notes: + if note.get('type') == 'note': nodes.append({ "id": note['path'], - "label": note['name'] + "label": note['name'].replace('.md', '') }) + + # Read note content to find links + content = get_note_content(config['storage']['notes_dir'], note['path']) + if content: + # 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) + + # Process wikilinks + for target in wikilinks: + target = target.strip() + target_lower = target.lower() + + # Try to match target to an existing note + target_path = None + + # 1. Exact path match + if target in note_paths: + target_path = target if target.endswith('.md') else target + '.md' + # 2. Path with .md extension + elif target + '.md' in note_paths: + target_path = target + '.md' + # 3. Case-insensitive path match (e.g., [[Folder/Note]] -> folder/note.md) + elif target_lower in note_paths_lower: + target_path = note_paths_lower[target_lower] + elif target_lower + '.md' in note_paths_lower: + target_path = note_paths_lower[target_lower + '.md'] + # 4. Just note name (case-insensitive) + elif target_lower in note_names: + target_path = note_names[target_lower] + + if target_path and target_path != note['path']: + edges.append({ + "source": note['path'], + "target": target_path, + "type": "wikilink" + }) + + # Process markdown links + for _, link_path in markdown_links: + # Try exact match first, then case-insensitive + target_path = None + 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()] + + if target_path and target_path != note['path']: + edges.append({ + "source": note['path'], + "target": target_path, + "type": "markdown" + }) - # Note: Link detection between notes could be implemented here using markdown link parsing - # For now, returning empty edges + # Remove duplicate edges + seen = set() + unique_edges = [] + for edge in edges: + key = (edge['source'], edge['target']) + if key not in seen: + seen.add(key) + unique_edges.append(edge) - return {"nodes": nodes, "edges": edges} + return {"nodes": nodes, "edges": unique_edges} except Exception as e: raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to generate graph data")) diff --git a/documentation/API.md b/documentation/API.md index fd3c567..f570533 100644 --- a/documentation/API.md +++ b/documentation/API.md @@ -271,7 +271,25 @@ GET /api/plugins/note_stats/calculate?content={markdown_content} ```http GET /api/graph ``` -Returns the relationship graph between notes (internal links). +Returns the relationship graph between notes with link detection. + +**Response:** +```json +{ + "nodes": [ + { "id": "folder/note.md", "label": "note" }, + { "id": "another.md", "label": "another" } + ], + "edges": [ + { "source": "folder/note.md", "target": "another.md", "type": "wikilink" } + ] +} +``` + +**Link Detection:** +- **Wikilinks** - `[[note]]` or `[[note|display text]]` syntax (Obsidian-style) +- **Markdown links** - `[text](note.md)` standard internal links +- **Edge types** - `"wikilink"` or `"markdown"` to distinguish link source ## ⚙️ System diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md index 9b6be0a..e44341b 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -29,7 +29,10 @@ ## 🔗 Linking & Discovery ### Internal Links -- **Markdown links** - `[Note Name](note.md)` syntax +- **Wikilinks** - `[[Note Name]]` Obsidian-style syntax for quick linking +- **Wikilinks with display text** - `[[Note Name|Click here]]` to customize link text +- **Broken link detection** - Non-existent note links shown dimmed +- **Markdown links** - `[Note Name](note.md)` standard syntax also supported - **Drag to link** - Drag notes or images into the editor to insert links - **Click to navigate** - Jump between notes seamlessly - **External links** - Open in new tabs automatically @@ -62,7 +65,8 @@ - **Reading time** - Estimated minutes to read - **Line count** - Total lines in note - **Image count** - Track embedded images -- **Link count** - Internal and external links +- **Link count** - Internal and external links (includes wikilinks) +- **Wikilink count** - Separate count for `[[wikilinks]]` - **Expandable panel** - Toggle stats visibility ## 🔌 Plugin System diff --git a/documentation/PLUGIN_NOTE_STATISTICS.md b/documentation/PLUGIN_NOTE_STATISTICS.md index 2494dc5..1dd8c67 100644 --- a/documentation/PLUGIN_NOTE_STATISTICS.md +++ b/documentation/PLUGIN_NOTE_STATISTICS.md @@ -54,9 +54,10 @@ curl -X POST http://localhost:8000/api/plugins/note_stats/toggle \ - Displayed in minutes ### 🔗 Links & References -- **Total Links** - All `[text](url)` links -- **Internal Links** - Links to other notes (`.md` files) +- **Total Links** - All links (markdown + wikilinks) +- **Internal Links** - Links to other notes (`.md` files and wikilinks) - **External Links** - HTTP/HTTPS URLs +- **Wikilinks** - `[[note]]` and `[[note|display]]` count - **Images** - `![alt](image.png)` count ### 💻 Code Blocks @@ -191,7 +192,8 @@ Find orphaned notes or track reference density. **Words:** Split on whitespace, excluding code blocks **Reading Time:** `words / 200 minutes` -**Links:** Regex match `[text](url)` markdown links +**Links:** Regex match `[text](url)` markdown links + `[[wikilinks]]` +**Wikilinks:** Regex match `[[target]]` and `[[target|display]]` (Obsidian-style) **Tasks:** Match `- [ ]` and `- [x]` **Code Blocks:** Match ` ```language ` ` fences **Headings:** Match `#`, `##`, `###` at line start diff --git a/frontend/app.js b/frontend/app.js index e1d3ae4..0518248 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1495,17 +1495,35 @@ function noteApp() { // Skip if it's just an anchor link if (!notePath) return; - // Find the note by path - const note = this.notes.find(n => n.path === notePath); + // Find the note by path (try exact match first, then with .md extension) + const note = this.notes.find(n => + n.path === notePath || + n.path === notePath + '.md' + ); if (note) { - this.loadNote(notePath); + this.loadNote(note.path); } else { - // Try to find by name (in case link uses just the note name) - const noteByName = this.notes.find(n => n.name === notePath || n.name === notePath + '.md'); + // Try to find by name (in case link uses just the note name without path) + const noteByName = this.notes.find(n => + n.name === notePath || + n.name === notePath + '.md' || + // Also match by filename at end of path (case-insensitive) + n.name.toLowerCase() === notePath.toLowerCase() || + n.name.toLowerCase() === (notePath + '.md').toLowerCase() + ); if (noteByName) { this.loadNote(noteByName.path); } else { - alert(`Note not found: ${notePath}`); + // Last resort: case-insensitive path matching + const noteByPathCI = this.notes.find(n => + n.path.toLowerCase() === notePath.toLowerCase() || + n.path.toLowerCase() === (notePath + '.md').toLowerCase() + ); + if (noteByPathCI) { + this.loadNote(noteByPathCI.path); + } else { + alert(`Note not found: ${notePath}`); + } } } }, @@ -2638,6 +2656,50 @@ function noteApp() { } } + // Convert Obsidian-style wikilinks: [[note]] or [[note|display text]] + // Must be done before marked.parse() to avoid conflicts with markdown syntax + const notes = this.notes; // Reference for closure + contentToRender = contentToRender.replace( + /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, + (match, target, displayText) => { + const linkTarget = target.trim(); + const linkText = displayText ? displayText.trim() : linkTarget; + const linkTargetLower = linkTarget.toLowerCase(); + + // Check if note exists (by path or by name, case-insensitive) + const noteExists = notes.some(n => { + const pathLower = n.path.toLowerCase(); + const nameLower = n.name.toLowerCase(); + return ( + // Exact path match + n.path === linkTarget || + n.path === linkTarget + '.md' || + // Case-insensitive path match + pathLower === linkTargetLower || + pathLower === linkTargetLower + '.md' || + // Name match (with or without .md) + n.name === linkTarget || + n.name === linkTarget + '.md' || + nameLower === linkTargetLower || + nameLower === linkTargetLower + '.md' || + // Match by filename at end of path + n.path.endsWith('/' + linkTarget) || + n.path.endsWith('/' + linkTarget + '.md') || + pathLower.endsWith('/' + linkTargetLower) || + pathLower.endsWith('/' + linkTargetLower + '.md') + ); + }); + + // Escape special chars: href needs quote escaping, text needs HTML escaping + const safeHref = linkTarget.replace(/"/g, '%22'); + const safeText = linkText.replace(/&/g, '&').replace(//g, '>'); + + // Return link with data attribute for styling broken links + const brokenClass = noteExists ? '' : ' class="wikilink-broken"'; + return `${safeText}`; + } + ); + // Configure marked with syntax highlighting marked.setOptions({ breaks: true, @@ -2971,10 +3033,17 @@ function noteApp() { // Tables: markdown table separator rows (| --- | --- |) const tables = (content.match(/^\s*\|(?:\s*:?-+:?\s*\|){1,}\s*$/gm) || []).length; - // Link count - const linkMatches = content.match(/\[([^\]]+)\]\(([^\)]+)\)/g) || []; - const links = linkMatches.length; - const internalLinks = linkMatches.filter(l => l.includes('.md')).length; + // Link count (standard markdown links) + const markdownLinkMatches = content.match(/\[([^\]]+)\]\(([^\)]+)\)/g) || []; + const markdownLinks = markdownLinkMatches.length; + const markdownInternalLinks = markdownLinkMatches.filter(l => l.includes('.md')).length; + + // Wikilink count ([[note]] or [[note|display text]] format) + const wikilinks = (content.match(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g) || []).length; + + // Total links (markdown + wikilinks) + const links = markdownLinks + wikilinks; + const internalLinks = markdownInternalLinks + wikilinks; // All wikilinks are internal // Code blocks const codeBlocks = (content.match(/```[\s\S]*?```/g) || []).length; @@ -3010,6 +3079,7 @@ function noteApp() { links, internal_links: internalLinks, external_links: links - internalLinks, + wikilinks, code_blocks: codeBlocks, inline_code: inlineCode, headings: { diff --git a/frontend/index.html b/frontend/index.html index a1969f9..cabf349 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -266,16 +266,34 @@ text-decoration-color: var(--accent-primary); } - /* Internal links (not starting with http/https) get a special style */ - .markdown-preview a:not([href^="http"]):not([href^="https"]):not([href^="//"]):not([href^="mailto:"]) { - color: var(--accent-primary); + /* Wikilinks - Obsidian style internal links */ + .markdown-preview a[data-wikilink] { + color: var(--link-internal, var(--accent-primary)); + text-decoration: none; font-weight: 500; + background-color: var(--link-internal-bg, transparent); + padding: 0 2px; + border-radius: 2px; + transition: background-color 0.15s, color 0.15s; } - .markdown-preview a:not([href^="http"]):not([href^="https"]):not([href^="//"]):not([href^="mailto:"]):before { - content: "→ "; - font-size: 0.9em; + .markdown-preview a[data-wikilink]:hover { + background-color: var(--link-internal-hover-bg, var(--bg-tertiary)); + text-decoration: underline; + } + + /* Broken wikilinks - note doesn't exist (Obsidian shows these dimmed) */ + .markdown-preview a[data-wikilink].wikilink-broken { + color: var(--link-broken, var(--text-tertiary)); opacity: 0.7; } + .markdown-preview a[data-wikilink].wikilink-broken:hover { + opacity: 1; + } + + /* 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); + } /* Tables */ .markdown-preview table { diff --git a/plugins/note_stats.py b/plugins/note_stats.py index d1ebdb9..45423f6 100644 --- a/plugins/note_stats.py +++ b/plugins/note_stats.py @@ -54,11 +54,18 @@ class Plugin: re.findall(r'^\s*\|(?:\s*:?-+:?\s*\|){1,}\s*$', content, re.MULTILINE) ) - # Markdown link count - links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+)\)', content)) + # Markdown link count (standard [text](url) format) + markdown_links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+)\)', content)) - # Internal link count (links to .md files) - internal_links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+\.md)\)', content)) + # Internal link count (standard markdown links to .md files) + markdown_internal_links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+\.md)\)', content)) + + # Wikilink count ([[note]] or [[note|display text]] format - Obsidian style) + wikilinks = len(re.findall(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]', content)) + + # Total links and internal links + links = markdown_links + wikilinks + internal_links = markdown_internal_links + wikilinks # All wikilinks are internal # Code block count code_blocks = len(re.findall(r'```[\s\S]*?```', content)) @@ -95,6 +102,7 @@ class Plugin: 'links': links, 'internal_links': internal_links, 'external_links': links - internal_links, + 'wikilinks': wikilinks, 'code_blocks': code_blocks, 'inline_code': inline_code, 'headings': {