backlink retrieval

This commit is contained in:
Gamosoft 2026-03-24 10:58:32 +01:00
parent c466f0b16f
commit 57032c6c28
2 changed files with 125 additions and 5 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

@ -984,3 +984,117 @@ 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
"""
import urllib.parse
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