diff --git a/backend/main.py b/backend/main.py index 8331ad3..325ea42 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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: diff --git a/backend/utils.py b/backend/utils.py index 93d88e2..7afec6a 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -984,3 +984,117 @@ 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 + """ + 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 +