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, get_template_content,
apply_template_placeholders, apply_template_placeholders,
paginate, paginate,
get_backlinks,
) )
from .plugins import PluginManager from .plugins import PluginManager
from .themes import get_available_themes, get_theme_css 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"]) @api_router.get("/notes/{note_path:path}", tags=["Notes"])
async def get_note(note_path: str): async def get_note(note_path: str, include_backlinks: bool = True):
"""Get a specific note's content""" """Get a specific note's content with optional backlinks"""
try: try:
content = get_note_content(config['storage']['notes_dir'], note_path) content = get_note_content(config['storage']['notes_dir'], note_path)
if content is None: if content is None:
raise HTTPException(status_code=404, detail="Note not found") raise HTTPException(status_code=404, detail="Note not found")
# Run on_note_load hook (can transform content, e.g., decrypt) # 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) transformed_content = plugin_manager.run_hook('on_note_load', note_path=note_path, content=content)
if transformed_content is not None: if transformed_content is not None:
content = transformed_content content = transformed_content
return { response = {
"path": note_path, "path": note_path,
"content": content, "content": content,
"metadata": create_note_metadata(config['storage']['notes_dir'], note_path) "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: except HTTPException:
raise raise
except Exception as e: except Exception as e:

View File

@ -7,6 +7,7 @@ import re
import shutil import shutil
import threading import threading
import time import time
import urllib.parse
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import List, Dict, Optional, Tuple, Any, TypeVar, Callable 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 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 ```http
GET /api/notes/{note_path} 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:** **Example:**
```bash ```bash
curl http://localhost:8000/api/notes/folder/mynote.md 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 ### Create/Update Note
```http ```http
POST /api/notes/{note_path} POST /api/notes/{note_path}

View File

@ -60,6 +60,14 @@
- **Hierarchical view** - Indentation shows heading structure - **Hierarchical view** - Indentation shows heading structure
- **Heading count badge** - Quick indicator of document 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 ### Section Link Syntax
To link to a heading, convert the heading text to a slug: **lowercase, spaces → dashes, remove special chars**. 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 - **Browse tags** - AI understands your organization
- **Create notes** - AI can save summaries and insights - **Create notes** - AI can save summaries and insights
- **Knowledge graph** - AI can explore note relationships - **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 - **Zero setup** - Works with Docker or Python, just add config to Cursor/Claude
### Quick Setup (Docker) ### 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) - 📂 **Organize** notes (move, rename, folders)
- 📋 **Use templates** to create structured notes - 📋 **Use templates** to create structured notes
- 🔗 **Explore** the knowledge graph - 🔗 **Explore** the knowledge graph
- 🔙 **Discover backlinks** (notes that link to a specific note)
## Quick Setup ## Quick Setup
@ -126,6 +127,7 @@ The MCP server provides these tools to AI assistants:
| `list_tags` | List all tags with note counts | | `list_tags` | List all tags with note counts |
| `get_notes_by_tag` | Find notes with a specific tag (supports pagination) | | `get_notes_by_tag` | Find notes with a specific tag (supports pagination) |
| `get_graph` | Get knowledge graph data | | `get_graph` | Get knowledge graph data |
| `get_backlinks` | Get notes that link to a specific note (reverse links) |
### Note Management ### 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_to_note`
Append content to an existing note without overwriting. Perfect for journals, logs, or collecting ideas. 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." > "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 ## Authentication
If you have authentication enabled in NoteDiscovery: If you have authentication enabled in NoteDiscovery:

View File

@ -236,7 +236,10 @@ function noteApp() {
// Outline (TOC) state // Outline (TOC) state
outline: [], // [{level: 1, text: 'Heading', slug: 'heading'}, ...] 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 // Scroll sync state
isScrolling: false, isScrolling: false,
@ -524,6 +527,7 @@ function noteApp() {
this.noteContent = ''; this.noteContent = '';
this.currentNoteName = ''; this.currentNoteName = '';
this.outline = []; this.outline = [];
this.backlinks = [];
this.shareInfo = null; // Reset share info this.shareInfo = null; // Reset share info
document.title = this.appName; document.title = this.appName;
@ -1315,6 +1319,7 @@ function noteApp() {
extractOutline(content) { extractOutline(content) {
if (!content) { if (!content) {
this.outline = []; this.outline = [];
this.backlinks = [];
return; 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 // Unified filtering logic combining tags and text search
async applyFilters() { async applyFilters() {
@ -2750,7 +2760,10 @@ function noteApp() {
// Extract outline for TOC panel // Extract outline for TOC panel
this.extractOutline(data.content); this.extractOutline(data.content);
// Store backlinks from API response
this.backlinks = data.backlinks || [];
// Initialize undo/redo history for this note (with cursor at start) // Initialize undo/redo history for this note (with cursor at start)
this.undoHistory = [{ content: data.content, cursorPos: 0 }]; this.undoHistory = [{ content: data.content, cursorPos: 0 }];
this.redoHistory = []; this.redoHistory = [];
@ -5625,6 +5638,7 @@ function noteApp() {
this.noteContent = ''; this.noteContent = '';
this.currentMedia = ''; this.currentMedia = '';
this.outline = []; this.outline = [];
this.backlinks = [];
document.title = this.appName; document.title = this.appName;
// Invalidate cache to force recalculation // Invalidate cache to force recalculation
@ -5647,6 +5661,7 @@ function noteApp() {
this.noteContent = ''; this.noteContent = '';
this.currentMedia = ''; this.currentMedia = '';
this.outline = []; this.outline = [];
this.backlinks = [];
this.mobileSidebarOpen = false; this.mobileSidebarOpen = false;
document.title = this.appName; document.title = this.appName;

View File

@ -1453,6 +1453,28 @@
x-text="outline.length > 9 ? '9+' : outline.length" x-text="outline.length > 9 ? '9+' : outline.length"
></span> ></span>
</button> </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> <div class="icon-rail-spacer"></div>
@ -1847,7 +1869,75 @@
</div> </div>
</div> </div>
<!-- END OUTLINE PANEL --> <!-- 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 ==================== --> <!-- ==================== SETTINGS PANEL ==================== -->
<template x-if="activePanel === 'settings'"> <template x-if="activePanel === 'settings'">
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">

View File

@ -156,6 +156,14 @@
"hint": "Überschriften mit # hinzufügen" "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": { "stats": {
"words": "Wörter", "words": "Wörter",
"reading_time": "~{{minutes}} Min. Lesezeit", "reading_time": "~{{minutes}} Min. Lesezeit",

View File

@ -155,6 +155,14 @@
"hint": "Add headings using # syntax" "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": { "stats": {
"words": "words", "words": "words",
"reading_time": "~{{minutes}}m read", "reading_time": "~{{minutes}}m read",

View File

@ -156,6 +156,14 @@
"hint": "Add headings using # syntax" "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": { "stats": {
"words": "words", "words": "words",
"reading_time": "~{{minutes}}m read", "reading_time": "~{{minutes}}m read",

View File

@ -156,6 +156,14 @@
"hint": "Añade encabezados usando sintaxis #" "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": { "stats": {
"words": "palabras", "words": "palabras",
"reading_time": "~{{minutes}}m de lectura", "reading_time": "~{{minutes}}m de lectura",

View File

@ -156,6 +156,14 @@
"hint": "Ajoutez des titres avec la syntaxe #" "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": { "stats": {
"words": "mots", "words": "mots",
"reading_time": "~{{minutes}}m de lecture", "reading_time": "~{{minutes}}m de lecture",

View File

@ -156,6 +156,14 @@
"hint": "Fejléc hozzáadása a következővel: # " "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": { "stats": {
"words": "szavak", "words": "szavak",
"reading_time": "~{{minutes}} percnyi olvasás", "reading_time": "~{{minutes}} percnyi olvasás",

View File

@ -155,6 +155,14 @@
"hint": "Aggiungi titoli usando la sintassi #" "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": { "stats": {
"words": "parole", "words": "parole",
"reading_time": "~{{minutes}}m lettura", "reading_time": "~{{minutes}}m lettura",

View File

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

View File

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

View File

@ -155,6 +155,14 @@
"hint": "Dodajte naslove s sintakso #" "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": { "stats": {
"words": "besed", "words": "besed",
"reading_time": "~{{minutes}} min branja", "reading_time": "~{{minutes}} min branja",

View File

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

View File

@ -354,11 +354,24 @@ class NoteDiscoveryClient:
def get_graph(self) -> APIResponse: def get_graph(self) -> APIResponse:
""" """
Get note relationship graph data. Get note relationship graph data.
Returns: Returns:
APIResponse with graph nodes and links APIResponse with graph nodes and links
""" """
return self._request("GET", "/api/graph") 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 # Templates API

View File

@ -414,6 +414,41 @@ class MCPServer:
output.append(f" {note}: {count} connections") output.append(f" {note}: {count} connections")
return "\n".join(output) 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: def _tool_create_note(self, args: dict) -> str:
"""Create or update a note.""" """Create or update a note."""

View File

@ -114,6 +114,20 @@ TOOLS: list[dict[str, Any]] = [
"required": [] "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) # Note Management (Write Operations)