Merge pull request #189 from gamosoft/features/backlink

Features/backlink
This commit is contained in:
Guillermo Villar 2026-03-24 12:09:25 +01:00 committed by GitHub
commit e1ec38e0f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 468 additions and 11 deletions

View File

@ -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:

View File

@ -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

View File

@ -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}

View File

@ -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)

View File

@ -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:

View File

@ -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;

View File

@ -1453,6 +1453,28 @@
x-text="outline.length > 9 ? '9+' : outline.length"
></span>
</button>
<!-- Backlinks -->
<button
class="icon-rail-btn relative"
:class="{'active': activePanel === 'backlinks'}"
@click="activePanel = 'backlinks'"
:title="t('backlinks.title')"
:aria-label="t('backlinks.title')"
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
<!-- Backlinks count badge -->
<span
x-show="backlinks.length > 0"
x-cloak
class="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-bold rounded-full flex items-center justify-center"
style="background-color: var(--accent-primary); color: white;"
x-text="backlinks.length > 9 ? '9+' : backlinks.length"
></span>
</button>
<div class="icon-rail-spacer"></div>
@ -1847,7 +1869,75 @@
</div>
</div>
<!-- END OUTLINE PANEL -->
<!-- ==================== BACKLINKS PANEL ==================== -->
<div x-show="activePanel === 'backlinks'" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" class="flex flex-col h-full">
<!-- Backlinks Header -->
<div class="flex-shrink-0 px-3 py-2 border-b flex items-center justify-between" style="border-color: var(--border-primary);">
<span class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-tertiary);" x-text="t('backlinks.title')"></span>
<span class="text-xs px-1.5 py-0.5 rounded" style="background-color: var(--bg-tertiary); color: var(--text-tertiary);" x-text="backlinks.length"></span>
</div>
<!-- Scrollable content: Backlinks items -->
<div class="flex-1 overflow-y-auto custom-scrollbar">
<div class="py-2">
<!-- When note is open and has backlinks -->
<template x-if="currentNote && backlinks.length > 0">
<div>
<template x-for="(bl, index) in backlinks" :key="index">
<button
@click="navigateToBacklink(bl.path)"
class="hover-accent w-full text-left px-3 py-2 text-sm border-b"
style="color: var(--text-primary); border-color: var(--border-primary);"
>
<!-- Note name -->
<div class="flex items-center gap-2 mb-1">
<svg class="w-4 h-4 flex-shrink-0" style="color: var(--accent-primary);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<span class="font-medium truncate" x-text="bl.name"></span>
</div>
<!-- References/Context snippets -->
<template x-for="(ref, refIdx) in bl.references.slice(0, 2)" :key="refIdx">
<div class="ml-6 text-xs truncate" style="color: var(--text-tertiary);">
<span class="opacity-60" x-text="'L' + ref.line_number + ':'"></span>
<span x-text="ref.context"></span>
</div>
</template>
<template x-if="bl.references.length > 2">
<div class="ml-6 text-xs" style="color: var(--text-tertiary);">
<span x-text="'+' + (bl.references.length - 2) + ' ' + t('backlinks.more_refs')"></span>
</div>
</template>
</button>
</template>
</div>
</template>
<!-- When note is open but no backlinks -->
<template x-if="currentNote && backlinks.length === 0">
<div class="text-center py-6 px-3">
<svg class="w-12 h-12 mx-auto mb-3 opacity-40" style="color: var(--text-tertiary);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
<p class="text-sm mb-2" style="color: var(--text-tertiary);" x-text="t('backlinks.no_backlinks')"></p>
<p class="text-xs" style="color: var(--text-tertiary);" x-text="t('backlinks.hint')"></p>
</div>
</template>
<!-- When no note is open -->
<template x-if="!currentNote">
<div class="text-center py-6 px-3">
<svg class="w-12 h-12 mx-auto mb-3 opacity-40" style="color: var(--text-tertiary);" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<p class="text-sm" style="color: var(--text-tertiary);" x-text="t('backlinks.select_note')"></p>
</div>
</template>
</div>
</div>
</div>
<!-- END BACKLINKS PANEL -->
<!-- ==================== SETTINGS PANEL ==================== -->
<template x-if="activePanel === 'settings'">
<div class="flex flex-col h-full">

View File

@ -156,6 +156,14 @@
"hint": "Überschriften mit # hinzufügen"
},
"backlinks": {
"title": "Rückverweise",
"no_backlinks": "Keine Rückverweise gefunden",
"hint": "Andere Notizen erscheinen hier, wenn sie auf diese Notiz verweisen",
"select_note": "Wählen Sie eine Notiz um Rückverweise zu sehen",
"more_refs": "mehr"
},
"stats": {
"words": "Wörter",
"reading_time": "~{{minutes}} Min. Lesezeit",

View File

@ -155,6 +155,14 @@
"hint": "Add headings using # syntax"
},
"backlinks": {
"title": "Backlinks",
"no_backlinks": "No backlinks found",
"hint": "Other notes will appear here when they link to this note",
"select_note": "Select a note to see backlinks",
"more_refs": "more"
},
"stats": {
"words": "words",
"reading_time": "~{{minutes}}m read",

View File

@ -156,6 +156,14 @@
"hint": "Add headings using # syntax"
},
"backlinks": {
"title": "Backlinks",
"no_backlinks": "No backlinks found",
"hint": "Other notes will appear here when they link to this note",
"select_note": "Select a note to see backlinks",
"more_refs": "more"
},
"stats": {
"words": "words",
"reading_time": "~{{minutes}}m read",

View File

@ -156,6 +156,14 @@
"hint": "Añade encabezados usando sintaxis #"
},
"backlinks": {
"title": "Backlinks",
"no_backlinks": "No se encontraron backlinks",
"hint": "Otras notas aparecerán aquí cuando enlacen a esta nota",
"select_note": "Selecciona una nota para ver backlinks",
"more_refs": "más"
},
"stats": {
"words": "palabras",
"reading_time": "~{{minutes}}m de lectura",

View File

@ -156,6 +156,14 @@
"hint": "Ajoutez des titres avec la syntaxe #"
},
"backlinks": {
"title": "Rétroliens",
"no_backlinks": "Aucun rétrolien trouvé",
"hint": "D'autres notes apparaîtront ici lorsqu'elles feront référence à cette note",
"select_note": "Sélectionnez une note pour voir les rétroliens",
"more_refs": "de plus"
},
"stats": {
"words": "mots",
"reading_time": "~{{minutes}}m de lecture",

View File

@ -156,6 +156,14 @@
"hint": "Fejléc hozzáadása a következővel: # "
},
"backlinks": {
"title": "Visszahivatkozások",
"no_backlinks": "Nincsenek visszahivatkozások",
"hint": "Más jegyzetek itt jelennek meg, ha hivatkoznak erre a jegyzetre",
"select_note": "Válasszon jegyzetet a visszahivatkozások megtekintéséhez",
"more_refs": "további"
},
"stats": {
"words": "szavak",
"reading_time": "~{{minutes}} percnyi olvasás",

View File

@ -155,6 +155,14 @@
"hint": "Aggiungi titoli usando la sintassi #"
},
"backlinks": {
"title": "Backlink",
"no_backlinks": "Nessun backlink trovato",
"hint": "Altre note appariranno qui quando fanno riferimento a questa nota",
"select_note": "Seleziona una nota per vedere i backlink",
"more_refs": "altri"
},
"stats": {
"words": "parole",
"reading_time": "~{{minutes}}m lettura",

View File

@ -155,6 +155,14 @@
"hint": "# 記法で見出しを追加"
},
"backlinks": {
"title": "バックリンク",
"no_backlinks": "バックリンクが見つかりません",
"hint": "他のノートがこのノートにリンクすると、ここに表示されます",
"select_note": "バックリンクを表示するノートを選択",
"more_refs": "その他"
},
"stats": {
"words": "語",
"reading_time": "約{{minutes}}分で読了",

View File

@ -155,6 +155,14 @@
"hint": "Добавьте заголовки с помощью #"
},
"backlinks": {
"title": "Обратные ссылки",
"no_backlinks": "Обратные ссылки не найдены",
"hint": "Другие заметки появятся здесь, когда они ссылаются на эту заметку",
"select_note": "Выберите заметку, чтобы увидеть обратные ссылки",
"more_refs": "ещё"
},
"stats": {
"words": "слов",
"reading_time": "~{{minutes}} мин. чтения",

View File

@ -155,6 +155,14 @@
"hint": "Dodajte naslove s sintakso #"
},
"backlinks": {
"title": "Povratne povezave",
"no_backlinks": "Ni najdenih povratnih povezav",
"hint": "Druge beležke se bodo pojavile tukaj, ko se bodo nanašale na to beležko",
"select_note": "Izberite beležko za ogled povratnih povezav",
"more_refs": "več"
},
"stats": {
"words": "besed",
"reading_time": "~{{minutes}} min branja",

View File

@ -151,10 +151,18 @@
"outline": {
"title": "大纲",
"no_headings": "未找到标题",
"no_headings": "未找到标题",
"hint": "使用 # 语法添加标题"
},
"backlinks": {
"title": "反向链接",
"no_backlinks": "未找到反向链接",
"hint": "当其他笔记链接到此笔记时,它们将显示在这里",
"select_note": "选择一个笔记以查看反向链接",
"more_refs": "更多"
},
"stats": {
"words": "字数",
"reading_time": "~{{minutes}}分钟阅读",

View File

@ -354,11 +354,24 @@ class NoteDiscoveryClient:
def get_graph(self) -> APIResponse:
"""
Get note relationship graph data.
Returns:
APIResponse with graph nodes and links
"""
return self._request("GET", "/api/graph")
def get_backlinks(self, path: str) -> APIResponse:
"""
Get backlinks (reverse links) for a specific note.
Args:
path: Note path (e.g., "folder/note.md")
Returns:
APIResponse with note info and backlinks
"""
encoded_path = urllib.parse.quote(path, safe="")
return self._request("GET", f"/api/notes/{encoded_path}", params={"include_backlinks": "true"})
# =========================================================================
# Templates API

View File

@ -414,6 +414,41 @@ class MCPServer:
output.append(f" {note}: {count} connections")
return "\n".join(output)
def _tool_get_backlinks(self, args: dict) -> str:
"""Get backlinks (reverse links) for a note."""
path = args.get("path", "")
is_valid, error = self._validate_path(path)
if not is_valid:
return f"Error: {error}"
response = self.client.get_backlinks(path)
if not response.success:
return f"Failed to get backlinks: {response.error}"
data = response.data or {}
backlinks = data.get("backlinks", [])
note_path = data.get("path", path)
if not backlinks:
return f"No backlinks found for '{note_path}'. No other notes link to this note."
output = [f"Backlinks for '{note_path}': {len(backlinks)} note(s) link to this note\n"]
for bl in backlinks:
bl_path = bl.get("path", "unknown")
bl_name = bl.get("name", bl_path)
references = bl.get("references", [])
output.append(f"📄 {bl_name} ({bl_path})")
for ref in references:
line_num = ref.get("line_number", "?")
context = ref.get("context", "")
link_type = ref.get("type", "link")
output.append(f" Line {line_num} [{link_type}]: {context}")
return "\n".join(output)
def _tool_create_note(self, args: dict) -> str:
"""Create or update a note."""

View File

@ -114,6 +114,20 @@ TOOLS: list[dict[str, Any]] = [
"required": []
}
},
{
"name": "get_backlinks",
"description": "Get all notes that link TO a specific note (backlinks/reverse links). Use this to discover what other notes reference the current note, helping understand its importance and connections in the knowledge base.",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the note to find backlinks for (e.g., 'folder/note.md' or 'note.md')"
}
},
"required": ["path"]
}
},
# =========================================================================
# Note Management (Write Operations)