Merge pull request #189 from gamosoft/features/backlink
Features/backlink
This commit is contained in:
commit
e1ec38e0f1
|
|
@ -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:
|
||||
|
|
|
|||
113
backend/utils.py
113
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -155,6 +155,14 @@
|
|||
"hint": "# 記法で見出しを追加"
|
||||
},
|
||||
|
||||
"backlinks": {
|
||||
"title": "バックリンク",
|
||||
"no_backlinks": "バックリンクが見つかりません",
|
||||
"hint": "他のノートがこのノートにリンクすると、ここに表示されます",
|
||||
"select_note": "バックリンクを表示するノートを選択",
|
||||
"more_refs": "その他"
|
||||
},
|
||||
|
||||
"stats": {
|
||||
"words": "語",
|
||||
"reading_time": "約{{minutes}}分で読了",
|
||||
|
|
|
|||
|
|
@ -155,6 +155,14 @@
|
|||
"hint": "Добавьте заголовки с помощью #"
|
||||
},
|
||||
|
||||
"backlinks": {
|
||||
"title": "Обратные ссылки",
|
||||
"no_backlinks": "Обратные ссылки не найдены",
|
||||
"hint": "Другие заметки появятся здесь, когда они ссылаются на эту заметку",
|
||||
"select_note": "Выберите заметку, чтобы увидеть обратные ссылки",
|
||||
"more_refs": "ещё"
|
||||
},
|
||||
|
||||
"stats": {
|
||||
"words": "слов",
|
||||
"reading_time": "~{{minutes}} мин. чтения",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}}分钟阅读",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue