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,
|
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,8 +1077,8 @@ 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:
|
||||||
|
|
@ -1088,11 +1089,16 @@ async def get_note(note_path: str):
|
||||||
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:
|
||||||
|
|
|
||||||
113
backend/utils.py
113
backend/utils.py
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,9 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1430,6 +1435,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() {
|
||||||
const hasTextSearch = this.searchQuery.trim().length > 0;
|
const hasTextSearch = this.searchQuery.trim().length > 0;
|
||||||
|
|
@ -2751,6 +2761,9 @@ 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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1454,6 +1454,28 @@
|
||||||
></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>
|
||||||
|
|
||||||
<!-- Graph -->
|
<!-- Graph -->
|
||||||
|
|
@ -1848,6 +1870,74 @@
|
||||||
</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">
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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}}分で読了",
|
||||||
|
|
|
||||||
|
|
@ -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}} мин. чтения",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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}}分钟阅读",
|
||||||
|
|
|
||||||
|
|
@ -360,6 +360,19 @@ class NoteDiscoveryClient:
|
||||||
"""
|
"""
|
||||||
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
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -415,6 +415,41 @@ class MCPServer:
|
||||||
|
|
||||||
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."""
|
||||||
path = args.get("path", "")
|
path = args.get("path", "")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue