global in memory index for large vaults

This commit is contained in:
Gamosoft 2026-06-30 11:14:42 +02:00
parent 030565299a
commit 4b316b2cc7
4 changed files with 1741 additions and 444 deletions

View File

@ -45,6 +45,8 @@ from .utils import (
paginate,
get_backlinks,
)
from . import note_index
from .note_index import USE_NOTE_INDEX
from .plugins import PluginManager
from .themes import get_available_themes, get_theme_css
from .share import (
@ -265,6 +267,7 @@ plugin_manager = PluginManager(config['storage']['plugins_dir'])
# Run app startup hooks
plugin_manager.run_hook('on_app_startup')
# Mount static files
static_path = Path(__file__).parent.parent / "frontend"
app.mount("/static", StaticFiles(directory=static_path), name="static")
@ -1483,10 +1486,25 @@ async def search(
@api_router.get("/graph", tags=["Graph"])
async def get_graph():
"""Get graph data for note visualization with wikilink and markdown link detection"""
"""Get graph data for note visualization with wikilink and markdown link detection."""
try:
import re
import urllib.parse
# Fast path: index already holds resolved edges. Ensure a scan has
# populated it (first request after startup, or after an invalidate)
# then ask the facade for a snapshot.
if USE_NOTE_INDEX:
if not note_index.get_index().is_built():
scan_notes_fast_walk(config['storage']['notes_dir'], include_media=False)
indexed = note_index.try_graph_data()
if indexed is not None:
nodes_paths, edges_tuples = indexed
return {
"nodes": [{"id": p, "label": Path(p).stem} for p in nodes_paths],
"edges": [{"source": s, "target": t, "type": et} for (s, t, et) in edges_tuples],
}
notes, _folders = scan_notes_fast_walk(config['storage']['notes_dir'], include_media=False)
nodes = []
edges = []
@ -1692,6 +1710,19 @@ async def toggle_plugin(request: Request, plugin_name: str, enabled: dict):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to toggle plugin"))
# ============================================================================
# Index observability — internal counters & sizes for debugging
#
# Useful for confirming the index is doing what we expect (build_count >0,
# search_terms not empty, fingerprint_short_circuits incrementing on idle
# warm scans). Not rate-limited — cheap, no I/O, dict lookups only.
# ============================================================================
@api_router.get("/index/stats", tags=["System"])
async def get_index_stats():
return note_index.stats()
# ============================================================================
# Stats Endpoint (for dashboards)
# ============================================================================

1219
backend/note_index.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,9 @@
"""
Utility functions for file operations, search, and markdown processing
Utility functions for file operations, search, and markdown processing.
Heavy work (backlinks, graph, full-text search, tag aggregation) is delegated
to the in-memory index in note_index.py every facade function there is a
no-op when USE_NOTE_INDEX is False, so call sites here stay flag-free.
"""
import os
@ -7,6 +11,7 @@ import re
import shutil
import threading
import time
import traceback
import urllib.parse
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
@ -14,6 +19,9 @@ from pathlib import Path
from typing import List, Dict, Optional, Tuple, Any, TypeVar, Callable
from datetime import datetime, timezone
from . import note_index
from .note_index import NoteRecord, extract_links_from_content
# ============================================================================
# Pagination Support
@ -112,14 +120,23 @@ def paginate(
)
# In-memory cache for parsed tags
# Format: {file_path: (mtime, tags)}
_tag_cache: Dict[str, Tuple[float, List[str]]] = {}
# Notes tree scan cache (TTL).
# ============================================================================
# In-memory caches
#
# This avoids repeated full-directory walks when multiple endpoints (or the UI)
# request indexes in quick succession.
# Two layers:
# _tag_cache / _links_cache : per-file, mtime-keyed. Avoid re-parsing the
# same file on warm scans. Used by get_tags_and_links_cached.
# _SCAN_WALK_CACHE : per-scan TTL cache. Avoids repeated full-
# directory walks when several endpoints fire in quick succession.
#
# All three are invalidated on every mutation (save/delete/move). The
# note_index in note_index.py is the system of record for derived data
# (links, backlinks, tags-by-name, search) — these caches just exist to
# avoid double-reading files we already parsed.
# ============================================================================
_tag_cache: Dict[str, Tuple[float, List[str]]] = {}
_links_cache: Dict[str, Tuple[float, Dict[str, List[str]]]] = {}
_SCAN_WALK_CACHE_LOCK = threading.Lock()
_SCAN_WALK_CACHE_TTL_SECONDS = 1.0
@ -145,6 +162,66 @@ def _scan_cache_set(key: Tuple[str, bool], value: Tuple[List[Dict], List[str]])
_SCAN_WALK_CACHE[key] = (time.monotonic(), value)
def _scan_cache_invalidate() -> None:
"""Drop the TTL scan cache. Called from every mutation handler so callers
that hit the cache immediately after a save/delete/move see the new state
instead of stale data from the previous walk."""
with _SCAN_WALK_CACHE_LOCK:
_SCAN_WALK_CACHE.clear()
def get_tags_and_links_cached(
file_path: Path,
rel_path: Optional[str] = None,
) -> Tuple[List[str], Dict[str, List[str]]]:
"""Single-read fused extraction of tags + raw links, cached by mtime.
Three-tier lookup, cheapest first:
1. In-process per-file caches (warm scans on the live server)
2. NoteIndex (warm after snapshot load avoids file reads on cold start)
3. Read the file from disk
rel_path enables tier 2. scan_notes_fast_walk passes it; ad-hoc callers
can omit it and just get tiers 1 + 3.
"""
try:
mtime = file_path.stat().st_mtime
file_key = str(file_path)
tag_cached = _tag_cache.get(file_key)
link_cached = _links_cache.get(file_key)
tags_ok = tag_cached is not None and tag_cached[0] == mtime
links_ok = link_cached is not None and link_cached[0] == mtime
if tags_ok and links_ok:
return tag_cached[1], link_cached[1]
# Try the NoteIndex before reading the file. On a snapshot-warm
# startup, this turns a 10K-file cold scan into a no-I/O walk.
if rel_path is not None:
indexed = note_index.try_get_extraction(rel_path, mtime)
if indexed is not None:
idx_tags, idx_links = indexed
_tag_cache[file_key] = (mtime, idx_tags)
_links_cache[file_key] = (mtime, idx_links)
return idx_tags, idx_links
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
tags = tag_cached[1] if tags_ok else parse_tags(content)
links = link_cached[1] if links_ok else extract_links_from_content(content)
if not tags_ok:
_tag_cache[file_key] = (mtime, tags)
if not links_ok:
_links_cache[file_key] = (mtime, links)
return tags, links
except Exception:
return [], {"wikilinks": [], "mdlinks": []}
def validate_path_security(notes_dir: str, path: Path) -> bool:
"""
Validate that a path is within the notes directory (security check).
@ -176,15 +253,12 @@ def ensure_directories(config: dict):
def create_folder(notes_dir: str, folder_path: str) -> bool:
"""Create a new folder in the notes directory"""
"""Create a new folder in the notes directory."""
full_path = Path(notes_dir) / folder_path
# Security check
if not validate_path_security(notes_dir, full_path):
return False
full_path.mkdir(parents=True, exist_ok=True)
_scan_cache_invalidate()
return True
@ -219,60 +293,11 @@ def scan_notes_fast_walk(notes_dir: str, use_cache: bool = True, include_media:
_scan_cache_set(cache_key, normalized_value)
return normalized_value
# TODO: remove this flag and the legacy branch below once the parallel
# path has been validated in production with large vaults. To revert to
# the pre-parallel behavior, flip _USE_PARALLEL_TAG_SCAN to False.
_USE_PARALLEL_TAG_SCAN = True
# Walk the vault once. Tag/link extraction is deferred out of the walk
# (the expensive part is the file I/O) and parallelized below across files.
notes: List[Dict] = []
folders_set = set()
if not _USE_PARALLEL_TAG_SCAN:
# ===== LEGACY: sequential tag extraction inline with the walk =====
for root, dirnames, filenames in os.walk(notes_path):
# Skip descending into dot-dirs
dirnames[:] = [d for d in dirnames if not d.startswith('.')]
root_path = Path(root)
rel_folder = root_path.relative_to(notes_path).as_posix()
if rel_folder != "." and not rel_folder.startswith('.'):
folders_set.add(rel_folder)
for filename in filenames:
if filename.startswith('.'):
continue
full_path = root_path / filename
try:
st = full_path.stat()
except OSError:
continue
relative_path = full_path.relative_to(notes_path)
media_type = get_media_type(filename) if include_media else None
is_markdown = full_path.suffix.lower() == '.md'
should_include = is_markdown or (include_media and media_type is not None)
if not should_include:
continue
folder = relative_path.parent.as_posix()
tags = get_tags_cached(full_path) if is_markdown else []
notes.append({
"name": full_path.stem,
"path": relative_path.as_posix(),
"folder": "" if folder == "." else folder,
"modified": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
"size": st.st_size,
"type": media_type if media_type else "note",
"tags": tags,
})
else:
# ===== NEW: tag extraction deferred out of the walk and parallelized =====
# Indices of markdown entries in `notes` plus their full paths, so we can
# defer tag extraction (the expensive part) out of the walk and parallelize
# it across files. Tags are filled in after the walk completes.
md_to_tag: List[Tuple[int, Path]] = []
md_to_extract: List[Tuple[int, Path, str]] = [] # (note_index, full_path, rel_path)
for root, dirnames, filenames in os.walk(notes_path):
dirnames[:] = [d for d in dirnames if not d.startswith('.')]
@ -296,36 +321,71 @@ def scan_notes_fast_walk(notes_dir: str, use_cache: bool = True, include_media:
media_type = get_media_type(filename) if include_media else None
is_markdown = full_path.suffix.lower() == '.md'
should_include = is_markdown or (include_media and media_type is not None)
if not should_include:
continue
folder = relative_path.parent.as_posix()
rel_str = relative_path.as_posix()
notes.append({
"name": full_path.stem,
"path": relative_path.as_posix(),
"path": rel_str,
"folder": "" if folder == "." else folder,
"modified": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
"size": st.st_size,
"type": media_type if media_type else "note",
"tags": [],
"_mtime": st.st_mtime, # internal — popped before returning
})
if is_markdown:
md_to_tag.append((len(notes) - 1, full_path))
md_to_extract.append((len(notes) - 1, full_path, rel_str))
# Parallelize for large vaults (sequential branch avoids thread-spawn
# overhead for small vaults). get_tags_cached is mtime-keyed so this is
# mostly free on warm cache; the win is the cold first call.
if md_to_tag:
if len(md_to_tag) >= 50:
# Fused tag + raw-link extraction. mtime-keyed cache makes warm scans
# mostly free; cold scans win from parallel I/O.
#
# We don't read full content here — that would double the parsing cost
# for every scan, and the search index is the only thing that needs it.
# Search is built lazily on the first search request (see
# note_index.ensure_search_index_built), which keeps startup fast and
# only pays the cost when a user actually searches.
sources_raw: Dict[str, Dict[str, List[str]]] = {}
if md_to_extract:
# Pass rel_path so each call can hit the NoteIndex before falling
# back to a file read — critical for fast startup after a snapshot
# load on large vaults.
path_pairs = [(full, rel) for (_, full, rel) in md_to_extract]
if len(md_to_extract) >= 50:
workers = min(8, (os.cpu_count() or 4))
paths = [p for _, p in md_to_tag]
with ThreadPoolExecutor(max_workers=workers) as ex:
for (idx, _), tags in zip(md_to_tag, ex.map(get_tags_cached, paths)):
notes[idx]["tags"] = tags
extraction = list(ex.map(
lambda pair: get_tags_and_links_cached(pair[0], pair[1]),
path_pairs,
))
else:
for idx, p in md_to_tag:
notes[idx]["tags"] = get_tags_cached(p)
extraction = [get_tags_and_links_cached(full, rel) for (full, rel) in path_pairs]
for (idx, _full, rel_str), (tags, links) in zip(md_to_extract, extraction):
notes[idx]["tags"] = tags
sources_raw[rel_str] = links
# Populate the unified index. Cheap when the fingerprint matches (warm
# scan, nothing changed) — short-circuits internally. No-op when off.
notes_meta = [
NoteRecord(
path=n["path"],
name=n["name"],
folder=n["folder"],
modified=n["modified"],
size=n["size"],
type=n["type"],
mtime=n["_mtime"],
tags=tuple(n["tags"]),
)
for n in notes
]
note_index.populate_from_scan(notes_meta, folders_set, sources_raw)
for n in notes:
n.pop("_mtime", None)
value = (sorted(notes, key=lambda x: x.get('modified', ''), reverse=True), sorted(folders_set))
if use_cache:
@ -333,132 +393,106 @@ def scan_notes_fast_walk(notes_dir: str, use_cache: bool = True, include_media:
return value
def move_note(notes_dir: str, old_path: str, new_path: str) -> tuple[bool, str]:
"""Move a note to a different location
Returns:
Tuple of (success: bool, error_message: str)
"""
"""Move a note. Returns (success, error_message)."""
old_full_path = Path(notes_dir) / old_path
new_full_path = Path(notes_dir) / new_path
# Security checks
if not validate_path_security(notes_dir, old_full_path):
return False, "Invalid source path"
if not validate_path_security(notes_dir, new_full_path):
return False, "Invalid destination path"
if not old_full_path.exists():
return False, f"Source note does not exist: {old_path}"
# Check if target already exists (prevent overwriting)
if new_full_path.exists():
return False, f"A note already exists at: {new_path}"
# Invalidate cache for old path
old_key = str(old_full_path)
if old_key in _tag_cache:
del _tag_cache[old_key]
_drop_path_caches(old_full_path)
try:
# Create parent directory if needed
new_full_path.parent.mkdir(parents=True, exist_ok=True)
# Move the file
old_full_path.rename(new_full_path)
except Exception as e:
return False, f"Failed to move file: {str(e)}"
# Note: We don't automatically delete empty folders to preserve user's folder structure
note_index.on_note_renamed(notes_dir, old_full_path, new_full_path)
_scan_cache_invalidate()
return True, ""
def move_folder(notes_dir: str, old_path: str, new_path: str) -> tuple[bool, str]:
"""Move a folder to a different location
Returns:
Tuple of (success: bool, error_message: str)
"""
import shutil
"""Move a folder. Returns (success, error_message)."""
old_full_path = Path(notes_dir) / old_path
new_full_path = Path(notes_dir) / new_path
# Security checks
if not validate_path_security(notes_dir, old_full_path):
return False, "Invalid source path"
if not validate_path_security(notes_dir, new_full_path):
return False, "Invalid destination path"
if not old_full_path.exists() or not old_full_path.is_dir():
return False, f"Source folder does not exist: {old_path}"
# Check if target already exists
if new_full_path.exists():
return False, f"A folder already exists at: {new_path}"
# Invalidate cache for all notes in this folder
global _tag_cache
old_path_str = str(old_full_path)
keys_to_delete = [key for key in _tag_cache.keys() if key.startswith(old_path_str)]
for key in keys_to_delete:
del _tag_cache[key]
_drop_prefix_caches(old_full_path)
try:
# Create parent directory if needed
new_full_path.parent.mkdir(parents=True, exist_ok=True)
# Move the folder
shutil.move(str(old_full_path), str(new_full_path))
except Exception as e:
return False, f"Failed to move folder: {str(e)}"
# Note: We don't automatically delete empty folders to preserve user's folder structure
note_index.on_folder_renamed(notes_dir, old_full_path, new_full_path)
_scan_cache_invalidate()
return True, ""
def rename_folder(notes_dir: str, old_path: str, new_path: str) -> tuple[bool, str]:
"""Rename a folder (same as move but for clarity)"""
"""Rename a folder (same as move, named for clarity)."""
return move_folder(notes_dir, old_path, new_path)
def delete_folder(notes_dir: str, folder_path: str) -> bool:
"""Delete a folder and all its contents"""
"""Delete a folder and all its contents."""
try:
full_path = Path(notes_dir) / folder_path
# Security check: ensure the path is within notes_dir
if not validate_path_security(notes_dir, full_path):
print(f"Security: Path is outside notes directory: {full_path}")
return False
if not full_path.exists():
print(f"Folder does not exist: {full_path}")
return False
if not full_path.is_dir():
print(f"Path is not a directory: {full_path}")
return False
# Invalidate cache for all notes in this folder
global _tag_cache
folder_path_str = str(full_path)
keys_to_delete = [key for key in _tag_cache.keys() if key.startswith(folder_path_str)]
for key in keys_to_delete:
del _tag_cache[key]
# Delete the folder and all its contents
_drop_prefix_caches(full_path)
shutil.rmtree(full_path)
print(f"Successfully deleted folder: {full_path}")
note_index.on_folder_deleted(notes_dir, full_path)
_scan_cache_invalidate()
return True
except Exception as e:
print(f"Error deleting folder '{folder_path}': {e}")
import traceback
traceback.print_exc()
return False
def _drop_path_caches(full_path: Path) -> None:
"""Evict the per-file mtime caches for a single note."""
key = str(full_path)
_tag_cache.pop(key, None)
_links_cache.pop(key, None)
def _drop_prefix_caches(folder_full_path: Path) -> None:
"""Evict per-file mtime caches for every entry under a folder."""
prefix = str(folder_full_path)
for k in [k for k in _tag_cache if k.startswith(prefix)]:
_tag_cache.pop(k, None)
for k in [k for k in _links_cache if k.startswith(prefix)]:
_links_cache.pop(k, None)
def get_note_content(notes_dir: str, note_path: str) -> Optional[str]:
@ -477,110 +511,111 @@ def get_note_content(notes_dir: str, note_path: str) -> Optional[str]:
def save_note(notes_dir: str, note_path: str, content: str) -> bool:
"""Save or update a note"""
"""Save or update a note."""
full_path = Path(notes_dir) / note_path
# Ensure .md extension
if not note_path.endswith('.md'):
full_path = full_path.with_suffix('.md')
# Security check
if not validate_path_security(notes_dir, full_path):
return False
# Create parent directories if needed
full_path.parent.mkdir(parents=True, exist_ok=True)
with open(full_path, 'w', encoding='utf-8') as f:
f.write(content)
# Refresh the per-file mtime caches with what we just wrote, so the next
# scan doesn't re-parse this file. extract_links + parse_tags + save are
# tiny relative to the file write.
try:
mtime = full_path.stat().st_mtime
file_key = str(full_path)
_tag_cache[file_key] = (mtime, parse_tags(content))
_links_cache[file_key] = (mtime, extract_links_from_content(content))
except Exception:
pass # caches are best-effort
note_index.on_note_saved(notes_dir, full_path, content)
_scan_cache_invalidate()
return True
def delete_note(notes_dir: str, note_path: str) -> bool:
"""Delete a note"""
"""Delete a note."""
full_path = Path(notes_dir) / note_path
if not full_path.exists():
return False
# Security check
if not validate_path_security(notes_dir, full_path):
return False
# Invalidate cache for this note
file_key = str(full_path)
if file_key in _tag_cache:
del _tag_cache[file_key]
_drop_path_caches(full_path)
full_path.unlink()
# Note: We don't automatically delete empty folders to preserve user's folder structure
note_index.on_note_deleted(notes_dir, full_path)
_scan_cache_invalidate()
return True
def search_notes(notes_dir: str, query: str) -> List[Dict]:
"""
Full-text search through note contents only.
Does NOT search in file names, folder names, or paths - only note content.
Uses character-based context extraction with highlighted matches.
"""Full-text search through note contents.
Only searches inside note content (not filenames or paths). The index, when
available, narrows down the candidate files via a token-AND match on its
inverted index then the existing per-line snippet extraction runs on the
narrow set. Output is byte-identical to a full scan.
"""
from html import escape
results = []
results: List[Dict] = []
notes, _folders = scan_notes_fast_walk(notes_dir, include_media=False)
# Build the search index lazily on the first search request. Subsequent
# searches hit the warm index; the first one pays the cost (read every
# file once) and is roughly as slow as the legacy full scan would be.
note_index.ensure_search_index(notes_dir)
candidates = note_index.try_search_candidates(query)
for note in notes:
md_file = Path(notes_dir) / note["path"]
path = note["path"]
if candidates is not None and path not in candidates:
continue
md_file = Path(notes_dir) / path
try:
with open(md_file, 'r', encoding='utf-8') as f:
content = f.read()
# Find all matches using regex (case-insensitive)
matches = list(re.finditer(re.escape(query), content, re.IGNORECASE))
if not matches:
continue
if matches:
matched_lines = []
for match in matches[:3]: # Limit to 3 matches per file
for match in matches[:3]:
start_index = match.start()
end_index = match.end()
matched_text = match.group() # Preserve original case
matched_text = match.group()
# Create slice window: ±15 characters around match
context_start = max(0, start_index - 15)
context_end = min(len(content), end_index + 15)
# Extract and clean parts (newlines → spaces)
before = escape(content[context_start:start_index].replace('\n', ' '))
after = escape(content[end_index:context_end].replace('\n', ' '))
matched_clean = escape(matched_text.replace('\n', ' '))
# Build snippet with <mark> highlight (styled via CSS)
snippet = f'{before}<mark class="search-highlight">{matched_clean}</mark>{after}'
# Add ellipsis if truncated at start
if context_start > 0:
snippet = '...' + snippet
# Add ellipsis if truncated at end
if context_end < len(content):
snippet = snippet + '...'
# Calculate line number by counting newlines up to match start
line_number = content.count('\n', 0, start_index) + 1
matched_lines.append({
"line_number": line_number,
"context": snippet
"context": snippet,
})
relative_path = Path(note["path"])
relative_path = Path(path)
results.append({
"name": md_file.stem,
"path": str(relative_path.as_posix()),
"folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "",
"matches": matched_lines
"matches": matched_lines,
})
except Exception:
continue
@ -852,105 +887,66 @@ def parse_tags(content: str) -> List[str]:
return []
def get_tags_cached(file_path: Path) -> List[str]:
"""
Get tags for a file with caching based on modification time.
Args:
file_path: Path to the markdown file
Returns:
List of tags from the file (cached if mtime unchanged)
"""
global _tag_cache
try:
# Get current modification time
mtime = file_path.stat().st_mtime
file_key = str(file_path)
# Check cache
if file_key in _tag_cache:
cached_mtime, cached_tags = _tag_cache[file_key]
if cached_mtime == mtime:
# Cache hit! Return cached tags
return cached_tags
# Cache miss or stale - parse tags
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
tags = parse_tags(content)
# Update cache
_tag_cache[file_key] = (mtime, tags)
return tags
except Exception:
# If anything fails, return empty list
return []
def clear_tag_cache():
"""Clear the tag cache (useful for testing or manual cache invalidation)"""
global _tag_cache
_tag_cache.clear()
def get_all_tags(notes_dir: str) -> Dict[str, int]:
"""
Get all tags used across all notes with their count (cached).
"""All tags in the vault with note counts. Index-backed when available."""
# Fast path FIRST — when the index is ready it knows every tag count,
# so we skip the scan entirely (no disk walk, no JSON of 10K notes).
indexed = note_index.try_all_tags()
if indexed is not None:
return indexed
Args:
notes_dir: Directory containing notes
Returns:
Dictionary mapping tag names to note counts
"""
tag_counts = {}
# Legacy fallback: scan the vault and aggregate.
notes, _folders = scan_notes_fast_walk(notes_dir, include_media=False)
tag_counts: Dict[str, int] = {}
for note in notes:
md_file = Path(notes_dir) / note["path"]
# Get tags using cache
tags = get_tags_cached(md_file)
for tag in tags:
for tag in note.get("tags", []):
tag_counts[tag] = tag_counts.get(tag, 0) + 1
return dict(sorted(tag_counts.items()))
def get_notes_by_tag(notes_dir: str, tag: str) -> List[Dict]:
"""
Get all notes that have a specific tag (cached).
Args:
notes_dir: Directory containing notes
tag: Tag to filter by (case-insensitive)
Returns:
List of note dictionaries matching the tag
"""
matching_notes = []
"""All notes carrying `tag` (case-insensitive). Index-backed when available."""
tag_lower = tag.lower()
# Fast path FIRST: every field we need lives in the NoteRecord. Skip
# the vault scan when the index can answer directly.
indexed_paths = note_index.try_notes_by_tag(tag_lower)
if indexed_paths is not None:
idx = note_index.get_index()
records = [idx.get_note_record(p) for p in indexed_paths]
matching = [
{
"name": r.name,
"path": r.path,
"folder": r.folder,
"modified": r.modified,
"size": r.size,
"tags": list(r.tags),
}
for r in records
if r is not None and r.type == "note"
]
# Match legacy sort order (scan returns notes sorted by modified desc).
matching.sort(key=lambda n: n["modified"], reverse=True)
return matching
# Legacy fallback.
notes, _folders = scan_notes_fast_walk(notes_dir, include_media=False)
matching: List[Dict] = []
for note in notes:
md_file = Path(notes_dir) / note["path"]
# Get tags using cache
tags = get_tags_cached(md_file)
if note.get("type") != "note":
continue
tags = note.get("tags", [])
if tag_lower in tags:
matching_notes.append({
matching.append({
"name": note["name"],
"path": note["path"],
"folder": note["folder"],
"modified": note["modified"],
"size": note["size"],
"tags": tags
"tags": tags,
})
return matching_notes
return matching
# ============================================================================
@ -1107,69 +1103,38 @@ def apply_template_placeholders(content: str, note_path: str) -> str:
return result
def get_backlinks(notes_dir: str, target_note_path: str) -> List[Dict]:
def _extract_backlink_references(
notes_dir: str,
source_path: str,
target_path: str,
target_path_lower: str,
target_path_no_ext_lower: str,
wikilink_refs: set,
) -> List[Dict]:
"""Read one source file and pull line-level references that point to target.
Same regex + resolution rules as the legacy get_backlinks loop body; just
factored out so both the index-driven and the legacy code paths use the
exact same context-extraction logic.
"""
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_lower = target_path.lower()
target_path_no_ext = target_path.replace('.md', '')
target_path_no_ext_lower = target_path_no_ext.lower()
target_name = Path(target_path).stem.lower()
# For wikilinks: global name matching (find note anywhere by name)
wikilink_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
# Get source folder for resolving relative markdown links
source_folder = str(Path(source_path).parent).replace('\\', '/')
if source_folder == '.':
source_folder = ''
# 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
return []
lines = content.split('\n')
found_links = []
found_links: List[Dict] = []
for line_num, line in enumerate(lines, 1):
# Find wikilinks: [[target]] or [[target|display]]
# Wikilinks use GLOBAL matching (find note anywhere by name)
wikilink_matches = re.finditer(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]', line)
for match in wikilink_matches:
for match in re.finditer(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]', line):
link_target = match.group(1).strip().lower()
link_target_no_ext = link_target.replace('.md', '')
# Check if this wikilink points to our target (global match)
if link_target in wikilink_refs or link_target_no_ext in wikilink_refs:
start = max(0, match.start() - 30)
end = min(len(line), match.end() + 30)
@ -1178,47 +1143,34 @@ def get_backlinks(notes_dir: str, target_note_path: str) -> List[Dict]:
context = '...' + context
if end < len(line):
context = context + '...'
found_links.append({
"line_number": line_num,
"context": context,
"type": "wikilink"
"type": "wikilink",
})
# Find markdown links: [text](path)
# Markdown links must RESOLVE as paths (relative to source or absolute)
markdown_matches = re.finditer(r'\[([^\]]+)\]\((?!https?://|mailto:|#|data:)([^\)]+)\)', line)
for match in markdown_matches:
link_path = match.group(2).split('#')[0] # Remove anchor
for match in re.finditer(r'\[([^\]]+)\]\((?!https?://|mailto:|#|data:)([^\)]+)\)', line):
link_path = match.group(2).split('#')[0]
if not link_path:
continue
link_path = urllib.parse.unquote(link_path)
if link_path.startswith('./'):
link_path = link_path[2:]
# Add .md if not present
link_path_with_md = link_path if link_path.endswith('.md') else link_path + '.md'
# Resolve the link path to get the actual target
resolved_path = None
# 1. Try resolving relative to source folder
if source_folder and not link_path.startswith('/'):
relative_path = f"{source_folder}/{link_path_with_md}"
if relative_path.lower() == target_path_lower:
resolved_path = target_path
elif f"{source_folder}/{link_path}".lower() == target_path_no_ext_lower:
resolved_path = target_path
# 2. Try as absolute path from root
if not resolved_path:
if link_path_with_md.lower() == target_path_lower:
resolved_path = target_path
elif link_path.lower() == target_path_no_ext_lower:
resolved_path = target_path
# Only add if the resolved path matches the target
if resolved_path:
start = max(0, match.start() - 30)
end = min(len(line), match.end() + 30)
@ -1227,19 +1179,61 @@ def get_backlinks(notes_dir: str, target_note_path: str) -> List[Dict]:
context = '...' + context
if end < len(line):
context = context + '...'
found_links.append({
"line_number": line_num,
"context": context,
"type": "markdown"
"type": "markdown",
})
# If we found links in this note, add it to backlinks
if found_links:
return found_links
def get_backlinks(notes_dir: str, target_note_path: str) -> List[Dict]:
"""Find all notes that link TO the specified note.
Index-backed when available: the index hands us a superset of candidate
source files (O(1)), we read only those, then the per-line matcher
filters down to true matches with line-level context. Output is
byte-identical to a full vault scan.
"""
target_path = target_note_path
target_path_lower = target_path.lower()
target_path_no_ext_lower = target_path_lower.replace('.md', '')
wikilink_refs = {
target_path_lower,
target_path_no_ext_lower,
Path(target_path).stem.lower(),
}
# scan_notes_fast_walk is TTL-cached, so calling it here is a dict-lookup
# in the warm case. We need it for the canonical ordering of `notes`.
notes, _folders = scan_notes_fast_walk(notes_dir, include_media=False)
candidates = note_index.try_backlink_candidates(target_path)
backlinks: List[Dict] = []
for note in notes:
if note.get('type') != 'note':
continue
source_path = note['path']
if source_path == target_path:
continue
if candidates is not None and source_path not in candidates:
continue
refs = _extract_backlink_references(
notes_dir,
source_path,
target_path,
target_path_lower,
target_path_no_ext_lower,
wikilink_refs,
)
if refs:
backlinks.append({
"path": source_path,
"name": note['name'].replace('.md', ''),
"references": found_links[:3] # Limit to 3 references per note
"references": refs[:3],
})
return backlinks

View File

@ -580,6 +580,59 @@ Returns application statistics at a glance. Designed for dashboard widgets (e.g.
label: Version
```
### Get Index Stats
```http
GET /api/index/stats
```
Returns internal counters and sizes for the in-memory note index. Useful for confirming the index is built and observing its growth — handy when debugging slow endpoints or verifying that incremental updates are firing on every save/delete.
The index is rebuilt on the first scan after each process start and updated incrementally on every save/delete/move. It lives in process memory only — no disk persistence.
**Response:**
```json
{
"enabled": true,
"built": true,
"search_built": false,
"notes": 142,
"folders": 12,
"tags": 37,
"links_forward_entries": 89,
"links_backward_entries": 76,
"wikilink_tokens": 134,
"search_terms": 0,
"counters": {
"build_count": 1,
"last_build_ms": 12.4,
"last_built_at": "2026-03-17T14:32:00+00:00",
"incremental_updates": 8,
"fingerprint_short_circuits": 3,
"search_build_count": 0,
"last_search_build_ms": 0.0
}
}
```
| Field | Description |
|-------|-------------|
| `enabled` | Whether the index is active (always `true` in current builds) |
| `built` | `true` after the first vault scan completes |
| `search_built` | `true` after the first `/api/search` request (search index is built lazily) |
| `notes` | Number of note records currently held in the index |
| `folders` | Number of folder paths |
| `tags` | Number of unique tags |
| `links_forward_entries` | Number of notes that link out to at least one other note |
| `links_backward_entries` | Number of notes that have at least one incoming backlink |
| `wikilink_tokens` | Number of unique wikilink tokens (used for loose backlink matching by stem name) |
| `search_terms` | Number of unique terms in the full-text inverted search index (0 until first search) |
| `counters.build_count` | Total number of full index rebuilds since process start |
| `counters.last_build_ms` | Wall-clock time of the most recent full rebuild |
| `counters.last_built_at` | ISO timestamp of the most recent full rebuild |
| `counters.incremental_updates` | Number of single-note updates applied (save/delete/rename) |
| `counters.fingerprint_short_circuits` | Times a re-scan was skipped because the vault hash matched the previous scan |
| `counters.search_build_count` | Total number of search-index rebuilds |
| `counters.last_search_build_ms` | Wall-clock time of the most recent search-index rebuild |
### Health Check
```http
GET /health