diff --git a/backend/main.py b/backend/main.py index 8331ad3..325ea42 100644 --- a/backend/main.py +++ b/backend/main.py @@ -43,6 +43,7 @@ from .utils import ( get_template_content, apply_template_placeholders, paginate, + get_backlinks, ) from .plugins import PluginManager from .themes import get_available_themes, get_theme_css @@ -1076,23 +1077,28 @@ async def list_notes( @api_router.get("/notes/{note_path:path}", tags=["Notes"]) -async def get_note(note_path: str): - """Get a specific note's content""" +async def get_note(note_path: str, include_backlinks: bool = True): + """Get a specific note's content with optional backlinks""" try: content = get_note_content(config['storage']['notes_dir'], note_path) if content is None: raise HTTPException(status_code=404, detail="Note not found") - + # Run on_note_load hook (can transform content, e.g., decrypt) transformed_content = plugin_manager.run_hook('on_note_load', note_path=note_path, content=content) if transformed_content is not None: content = transformed_content - - return { + + response = { "path": note_path, "content": content, "metadata": create_note_metadata(config['storage']['notes_dir'], note_path) } + + if include_backlinks: + response["backlinks"] = get_backlinks(config['storage']['notes_dir'], note_path) + + return response except HTTPException: raise except Exception as e: diff --git a/backend/utils.py b/backend/utils.py index 93d88e2..a5f1dea 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -7,6 +7,7 @@ import re import shutil import threading import time +import urllib.parse from dataclasses import dataclass from pathlib import Path from typing import List, Dict, Optional, Tuple, Any, TypeVar, Callable @@ -984,3 +985,115 @@ def apply_template_placeholders(content: str, note_path: str) -> str: return result + +def get_backlinks(notes_dir: str, target_note_path: str) -> List[Dict]: + """ + Find all notes that link TO the specified note (reverse links / backlinks). + + Args: + notes_dir: Base directory containing notes + target_note_path: Path of the note to find backlinks for + + Returns: + List of backlink objects with path, context, and line_number + """ + backlinks = [] + notes, _folders = scan_notes_fast_walk(notes_dir, include_media=False) + + # Normalize target path for matching + target_path = target_note_path + target_path_no_ext = target_path.replace('.md', '') + target_name = Path(target_path).stem.lower() + + # Build set of ways to reference the target note + target_refs = { + target_path.lower(), + target_path_no_ext.lower(), + target_name, + } + + for note in notes: + if note.get('type') != 'note': + continue + + source_path = note['path'] + + # Skip self-references + if source_path == target_path: + continue + + # Read note content + full_path = Path(notes_dir) / source_path + try: + with open(full_path, 'r', encoding='utf-8') as f: + content = f.read() + except Exception: + continue + + lines = content.split('\n') + found_links = [] + + for line_num, line in enumerate(lines, 1): + # Find wikilinks: [[target]] or [[target|display]] + wikilink_matches = re.finditer(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]', line) + for match in wikilink_matches: + link_target = match.group(1).strip().lower() + link_target_no_ext = link_target.replace('.md', '') + + # Check if this link points to our target + if link_target in target_refs or link_target_no_ext in target_refs: + # Extract context around the match + start = max(0, match.start() - 30) + end = min(len(line), match.end() + 30) + context = line[start:end] + if start > 0: + context = '...' + context + if end < len(line): + context = context + '...' + + found_links.append({ + "line_number": line_num, + "context": context, + "type": "wikilink" + }) + + # Find markdown links: [text](path) + markdown_matches = re.finditer(r'\[([^\]]+)\]\((?!https?://|mailto:|#|data:)([^\)]+)\)', line) + for match in markdown_matches: + link_path = match.group(2).split('#')[0] # Remove anchor + if not link_path: + continue + + link_path = urllib.parse.unquote(link_path) + if link_path.startswith('./'): + link_path = link_path[2:] + + link_path_lower = link_path.lower() + link_path_no_ext = link_path_lower.replace('.md', '') + + # Check if this link points to our target + if link_path_lower in target_refs or link_path_no_ext in target_refs: + start = max(0, match.start() - 30) + end = min(len(line), match.end() + 30) + context = line[start:end] + if start > 0: + context = '...' + context + if end < len(line): + context = context + '...' + + found_links.append({ + "line_number": line_num, + "context": context, + "type": "markdown" + }) + + # If we found links in this note, add it to backlinks + if found_links: + backlinks.append({ + "path": source_path, + "name": note['name'].replace('.md', ''), + "references": found_links[:3] # Limit to 3 references per note + }) + + return backlinks + diff --git a/documentation/API.md b/documentation/API.md index 6719dae..01aecf8 100644 --- a/documentation/API.md +++ b/documentation/API.md @@ -35,13 +35,62 @@ curl "http://localhost:8000/api/notes?limit=20&offset=20" ```http GET /api/notes/{note_path} ``` -Retrieve the content of a specific note. +Retrieve the content of a specific note, including metadata and backlinks. + +**Query Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `include_backlinks` | boolean | `true` | Include backlinks (notes that link to this note) | **Example:** ```bash curl http://localhost:8000/api/notes/folder/mynote.md ``` +**Response:** +```json +{ + "path": "folder/mynote.md", + "content": "# My Note\n\nNote content here...", + "metadata": { + "created": "2026-03-15T10:00:00+01:00", + "modified": "2026-03-17T14:30:00+01:00", + "size": 1234, + "lines": 42 + }, + "backlinks": [ + { + "path": "meetings/standup.md", + "name": "standup", + "references": [ + { + "line_number": 15, + "context": "...discussed [[mynote]]...", + "type": "wikilink" + } + ] + } + ] +} +``` + +**Backlinks Response Fields:** + +| Field | Description | +|-------|-------------| +| `path` | Path of the note that links to this note | +| `name` | Display name of the linking note | +| `references` | Array of link occurrences (max 3 per note) | +| `references[].line_number` | Line number where the link appears | +| `references[].context` | Text snippet around the link | +| `references[].type` | Link type: `wikilink` or `markdown` | + +**Without Backlinks:** +```bash +curl "http://localhost:8000/api/notes/folder/mynote.md?include_backlinks=false" +``` + ### Create/Update Note ```http POST /api/notes/{note_path} diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md index 9d831f4..613f420 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -60,6 +60,14 @@ - **Hierarchical view** - Indentation shows heading structure - **Heading count badge** - Quick indicator of document structure +### Backlinks +- **Reverse link discovery** - See which notes link TO the current note +- **Context snippets** - Preview the surrounding text where links appear +- **Line numbers** - Know exactly where each reference is located +- **Link type detection** - Distinguishes wikilinks from markdown links +- **API access** - Query backlinks programmatically via REST API +- **MCP integration** - AI assistants can discover note relationships + ### Section Link Syntax To link to a heading, convert the heading text to a slug: **lowercase, spaces → dashes, remove special chars**. @@ -365,6 +373,7 @@ Built-in **Model Context Protocol (MCP)** server for AI assistant integration: - **Browse tags** - AI understands your organization - **Create notes** - AI can save summaries and insights - **Knowledge graph** - AI can explore note relationships +- **Discover backlinks** - AI can find what notes reference a specific note - **Zero setup** - Works with Docker or Python, just add config to Cursor/Claude ### Quick Setup (Docker) diff --git a/documentation/MCP.md b/documentation/MCP.md index 5d08297..ea02e4c 100644 --- a/documentation/MCP.md +++ b/documentation/MCP.md @@ -14,6 +14,7 @@ MCP (Model Context Protocol) is an open standard that allows AI assistants to se - 📂 **Organize** notes (move, rename, folders) - 📋 **Use templates** to create structured notes - 🔗 **Explore** the knowledge graph +- 🔙 **Discover backlinks** (notes that link to a specific note) ## Quick Setup @@ -126,6 +127,7 @@ The MCP server provides these tools to AI assistants: | `list_tags` | List all tags with note counts | | `get_notes_by_tag` | Find notes with a specific tag (supports pagination) | | `get_graph` | Get knowledge graph data | +| `get_backlinks` | Get notes that link to a specific note (reverse links) | ### Note Management @@ -172,6 +174,23 @@ Some tools support optional pagination parameters for large vaults: --- +### `get_backlinks` + +Find all notes that link TO a specific note (reverse links / backlinks). Useful for understanding how a note connects to your knowledge base. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `path` | string | Yes | Path to the note to find backlinks for | + +**Returns:** List of notes that contain links to the specified note, with context snippets showing where the link appears. + +**Example prompts:** +- "What notes link to my Project Alpha note?" +- "Show me the backlinks for meeting-notes.md" +- "Find all notes that reference my API documentation" + +--- + ### `append_to_note` Append content to an existing note without overwriting. Perfect for journals, logs, or collecting ideas. @@ -266,6 +285,12 @@ Once configured, you can interact with your notes naturally: > > "Created 'meetings/design-review-2024-03-13.md' from your meeting-notes template." +> **User:** "What notes link to my Project Alpha document?" +> +> **AI:** *Uses `get_backlinks` to find reverse links* +> +> "3 notes reference Project Alpha: your meeting notes from March 12, the quarterly review, and the team standup notes..." + ## Authentication If you have authentication enabled in NoteDiscovery: diff --git a/frontend/app.js b/frontend/app.js index 354e0c0..013ddfe 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -236,7 +236,10 @@ function noteApp() { // Outline (TOC) state outline: [], // [{level: 1, text: 'Heading', slug: 'heading'}, ...] - + + // Backlinks state + backlinks: [], // [{path: 'note.md', name: 'Note', references: [{line_number: 5, context: '...', type: 'wikilink'}]}] + // Scroll sync state isScrolling: false, @@ -524,6 +527,7 @@ function noteApp() { this.noteContent = ''; this.currentNoteName = ''; this.outline = []; + this.backlinks = []; this.shareInfo = null; // Reset share info document.title = this.appName; @@ -1315,6 +1319,7 @@ function noteApp() { extractOutline(content) { if (!content) { this.outline = []; + this.backlinks = []; return; } @@ -1429,6 +1434,11 @@ function noteApp() { } } }, + + // Navigate to a backlink (note that links to current note) + navigateToBacklink(backlinkPath) { + this.loadNote(backlinkPath); + }, // Unified filtering logic combining tags and text search async applyFilters() { @@ -2750,7 +2760,10 @@ function noteApp() { // Extract outline for TOC panel this.extractOutline(data.content); - + + // Store backlinks from API response + this.backlinks = data.backlinks || []; + // Initialize undo/redo history for this note (with cursor at start) this.undoHistory = [{ content: data.content, cursorPos: 0 }]; this.redoHistory = []; @@ -5625,6 +5638,7 @@ function noteApp() { this.noteContent = ''; this.currentMedia = ''; this.outline = []; + this.backlinks = []; document.title = this.appName; // Invalidate cache to force recalculation @@ -5647,6 +5661,7 @@ function noteApp() { this.noteContent = ''; this.currentMedia = ''; this.outline = []; + this.backlinks = []; this.mobileSidebarOpen = false; document.title = this.appName; diff --git a/frontend/index.html b/frontend/index.html index 7d862f3..f9209f5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1453,6 +1453,28 @@ x-text="outline.length > 9 ? '9+' : outline.length" > + + +
@@ -1847,7 +1869,75 @@ - + + +
+ +
+ + +
+ + +
+
+ + + + + + +
+
+
+ +