NoteDiscovery/backend/note_index.py

979 lines
38 KiB
Python
Raw Normal View History

"""
2026-06-30 09:59:25 +00:00
NoteIndex in-memory index of vault state: notes, folders, tags, links, search.
Backs /api/notes, /api/tags, /api/backlinks, /api/graph, /api/search. Keeps
everything thread-safe under one RLock. Process-memory only rebuilds on the
first /api/notes request after each process start (~1-3s on a 10K-note vault).
Updated incrementally on every save/delete/move/rename through the on_*
facades at the bottom of this file.
"""
from __future__ import annotations
import os
import re
import threading
import time
import urllib.parse
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
2026-06-30 09:59:25 +00:00
# Below this many markdown files, parallel tag extraction adds more overhead
# than it saves.
_PARALLEL_CUTOFF = 50
_PARALLEL_WORKERS = min(8, (os.cpu_count() or 4))
2026-06-30 09:59:25 +00:00
# Search tokenization. Min length 2 keeps single-letter noise out without
# losing common short queries like "go", "ai".
_SEARCH_TOKEN_RE = re.compile(r"[A-Za-z0-9_\-]{2,}")
_SEARCH_MIN_QUERY_LEN = 2
WIKILINK_RE = re.compile(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]')
MDLINK_RE = re.compile(r'\[([^\]]+)\]\((?!https?://|mailto:|#|data:)([^\)]+)\)')
def extract_links_from_content(content: str) -> Dict[str, List[str]]:
"""Pull raw wikilink targets and markdown-link paths out of note content."""
wikilinks = [m.strip() for m in WIKILINK_RE.findall(content)]
mdlinks = [link_path for _, link_path in MDLINK_RE.findall(content)]
return {"wikilinks": wikilinks, "mdlinks": mdlinks}
def extract_search_terms(content: str) -> Set[str]:
2026-06-30 09:59:25 +00:00
"""Tokenize content for the inverted search index."""
return {m.group(0).lower() for m in _SEARCH_TOKEN_RE.finditer(content)}
@dataclass
class NoteRecord:
2026-06-30 09:59:25 +00:00
"""One note's metadata. No content, no resolved links."""
path: str # vault-relative POSIX
name: str # stem (no extension)
folder: str # vault-relative POSIX, "" for root
modified: str # ISO timestamp
size: int # bytes
type: str # "note" | "image" | "audio" | ...
2026-06-30 09:59:25 +00:00
mtime: float # raw stat mtime
tags: Tuple[str, ...] = field(default_factory=tuple)
class NoteIndex:
2026-06-30 09:59:25 +00:00
"""Thread-safe in-memory index of vault state. Reads return copies so
callers don't have to hold the lock."""
def __init__(self) -> None:
self._lock = threading.RLock()
2026-06-30 09:59:25 +00:00
self._notes: Dict[str, NoteRecord] = {} # path -> record
self._folders: Set[str] = set()
self._tags_forward: Dict[str, Tuple[str, ...]] = {} # path -> tags
self._tags_backward: Dict[str, Set[str]] = {} # tag -> paths
2026-06-30 09:59:25 +00:00
self._raw_links: Dict[str, Dict[str, List[str]]] = {} # path -> {"wikilinks":[], "mdlinks":[]}
self._links_forward: Dict[str, Dict[str, str]] = {} # src -> {tgt: type}
self._links_backward: Dict[str, Set[str]] = {} # tgt -> {srcs}
self._wikilink_tokens: Dict[str, Set[str]] = {} # lower token -> {srcs} (loose match)
2026-06-30 09:59:25 +00:00
self._search_terms: Dict[str, Set[str]] = {} # term -> {paths}
2026-06-30 09:59:25 +00:00
# _search_built tracked separately — the search index is built lazily
# on the first /api/search call, after the cheaper notes/tags/links
# part is already built.
self._built = False
self._search_built = False
self._raw_fingerprint: Optional[int] = None # short-circuits no-op rebuilds
self._stats = {
"build_count": 0,
"last_build_ms": 0.0,
"last_built_at": None,
"incremental_updates": 0,
"fingerprint_short_circuits": 0,
"search_build_count": 0,
"last_search_build_ms": 0.0,
}
def is_built(self) -> bool:
with self._lock:
return self._built
def invalidate(self) -> None:
2026-06-30 09:59:25 +00:00
"""Mark as needing rebuild on next scan."""
with self._lock:
self._built = False
self._raw_fingerprint = None
def reset(self) -> None:
with self._lock:
self._notes.clear()
self._folders.clear()
self._tags_forward.clear()
self._tags_backward.clear()
self._raw_links.clear()
self._links_forward.clear()
self._links_backward.clear()
self._wikilink_tokens.clear()
self._search_terms.clear()
self._built = False
self._search_built = False
self._raw_fingerprint = None
def is_search_built(self) -> bool:
with self._lock:
return self._search_built
def ensure_search_index_built(self, notes_dir: str) -> bool:
2026-06-30 09:59:25 +00:00
"""Build the search index on demand. Returns True when ready, False
only if the main index isn't built yet. File I/O happens OUTSIDE the
lock so other reads aren't blocked."""
if self._search_built:
return True
with self._lock:
2026-06-30 09:59:25 +00:00
if self._search_built:
return True
if not self._built:
return False
paths_to_read = [p for p, r in self._notes.items() if r.type == "note"]
t0 = time.perf_counter()
base = Path(notes_dir)
terms_per_path: Dict[str, Set[str]] = {}
for rel in paths_to_read:
try:
2026-06-30 09:59:25 +00:00
with open(base / rel, "r", encoding="utf-8") as f:
terms_per_path[rel] = extract_search_terms(f.read())
except Exception:
terms_per_path[rel] = set()
with self._lock:
if self._search_built:
return True
self._search_terms.clear()
for path, terms in terms_per_path.items():
for term in terms:
self._search_terms.setdefault(term, set()).add(path)
self._search_built = True
self._stats["search_build_count"] += 1
self._stats["last_search_build_ms"] = (time.perf_counter() - t0) * 1000
return True
def bulk_set(
self,
notes_meta: List[NoteRecord],
folders: Iterable[str],
sources_raw: Dict[str, Dict[str, List[str]]],
) -> None:
2026-06-30 09:59:25 +00:00
"""Replace the entire index from a fresh scan. Short-circuits when
the input fingerprints to the same state we already hold (warm
scan, nothing changed)."""
new_fp = _fingerprint(notes_meta, sources_raw)
with self._lock:
if self._built and self._raw_fingerprint == new_fp:
self._stats["fingerprint_short_circuits"] += 1
return
t0 = time.perf_counter()
self._notes = {n.path: n for n in notes_meta}
self._folders = set(folders)
self._raw_links = {k: v for k, v in sources_raw.items()}
self._rebuild_tags_unlocked()
self._rebuild_links_unlocked()
if self._search_built:
self._prune_search_unlocked()
self._raw_fingerprint = new_fp
self._built = True
self._stats["build_count"] += 1
self._stats["last_build_ms"] = (time.perf_counter() - t0) * 1000
self._stats["last_built_at"] = datetime.now(tz=timezone.utc).isoformat()
def update_note(
self,
record: NoteRecord,
raw_links: Dict[str, List[str]],
content: Optional[str] = None,
) -> None:
2026-06-30 09:59:25 +00:00
"""Patch the index in place after a note is created or saved."""
with self._lock:
old_record = self._notes.get(record.path)
old_tags = old_record.tags if old_record else ()
self._notes[record.path] = record
if record.folder:
self._folders.add(record.folder)
new_tags = record.tags
if old_tags != new_tags:
for t in set(old_tags) - set(new_tags):
bucket = self._tags_backward.get(t)
if bucket is not None:
bucket.discard(record.path)
if not bucket:
del self._tags_backward[t]
for t in set(new_tags) - set(old_tags):
self._tags_backward.setdefault(t, set()).add(record.path)
self._tags_forward[record.path] = new_tags
self._raw_links[record.path] = raw_links
self._resolve_single_source_unlocked(record.path)
2026-06-30 09:59:25 +00:00
# Search index only gets patched if it's already been built —
# otherwise the first /api/search will build it from scratch.
if content is not None and self._search_built:
self._update_search_for_note_unlocked(record.path, content)
self._raw_fingerprint = None
self._stats["incremental_updates"] += 1
def remove_note(self, path: str) -> None:
"""A note was deleted. Drop everything that mentions it."""
with self._lock:
old_record = self._notes.pop(path, None)
if old_record is None:
return
# Tags
for t in old_record.tags:
bucket = self._tags_backward.get(t)
if bucket is not None:
bucket.discard(path)
if not bucket:
del self._tags_backward[t]
self._tags_forward.pop(path, None)
# Links: drop as source.
self._raw_links.pop(path, None)
old_targets = self._links_forward.pop(path, {})
for t in old_targets:
bucket = self._links_backward.get(t)
if bucket is not None:
bucket.discard(path)
if not bucket:
del self._links_backward[t]
# Links: drop as loose wikilink source.
empty_keys = []
for key, sources in self._wikilink_tokens.items():
sources.discard(path)
if not sources:
empty_keys.append(key)
for k in empty_keys:
del self._wikilink_tokens[k]
# Links: drop as target from every other source's forward dict.
sources_pointing_here = self._links_backward.pop(path, set())
for src in sources_pointing_here:
fwd = self._links_forward.get(src)
if fwd is not None and path in fwd:
del fwd[path]
if not fwd:
del self._links_forward[src]
# Search: drop from every term bucket. Linear in distinct terms
# this note contributed to, which is bounded by note size.
empty_terms = []
for term, paths in self._search_terms.items():
if path in paths:
paths.discard(path)
if not paths:
empty_terms.append(term)
for term in empty_terms:
del self._search_terms[term]
self._raw_fingerprint = None
self._stats["incremental_updates"] += 1
def rename_note(self, old_path: str, new_path: str) -> None:
2026-06-30 09:59:25 +00:00
"""Move all references from old_path to new_path. The path change can
affect how other notes' wikilinks resolve, so we wipe the resolved
link indexes for this note and rely on the next bulk_set to rebuild."""
if old_path == new_path:
return
with self._lock:
old_record = self._notes.pop(old_path, None)
if old_record is None:
return
new_record = NoteRecord(
path=new_path,
name=Path(new_path).stem,
folder=str(Path(new_path).parent).replace("\\", "/").lstrip(".").lstrip("/") or "",
modified=old_record.modified,
size=old_record.size,
type=old_record.type,
mtime=old_record.mtime,
tags=old_record.tags,
)
# Re-derive folder cleanly (the above lstrip dance is brittle).
folder = str(Path(new_path).parent).replace("\\", "/")
new_record.folder = "" if folder == "." else folder
self._notes[new_path] = new_record
if new_record.folder:
self._folders.add(new_record.folder)
if old_path in self._tags_forward:
tags = self._tags_forward.pop(old_path)
self._tags_forward[new_path] = tags
for t in tags:
bucket = self._tags_backward.get(t)
if bucket is not None:
bucket.discard(old_path)
bucket.add(new_path)
for paths in self._search_terms.values():
if old_path in paths:
paths.discard(old_path)
paths.add(new_path)
if old_path in self._raw_links:
self._raw_links[new_path] = self._raw_links.pop(old_path)
2026-06-30 09:59:25 +00:00
# Other sources that linked to old_path by stem name may now
# resolve to new_path (or not), so we wipe both forward & backward
# for old_path and let the next bulk_set re-resolve from scratch.
self._links_forward.pop(old_path, None)
for bucket in self._links_backward.values():
bucket.discard(old_path)
old_backlinks = self._links_backward.pop(old_path, set())
for src in old_backlinks:
fwd = self._links_forward.get(src)
if fwd is not None and old_path in fwd:
del fwd[old_path]
if not fwd:
del self._links_forward[src]
empty_keys = []
for key, sources in self._wikilink_tokens.items():
if old_path in sources:
sources.discard(old_path)
sources.add(new_path)
if not sources:
empty_keys.append(key)
for k in empty_keys:
del self._wikilink_tokens[k]
self._raw_fingerprint = None
2026-06-30 09:59:25 +00:00
self._built = False # force re-resolve on next bulk_set
self._stats["incremental_updates"] += 1
def rename_folder_prefix(self, old_prefix: str, new_prefix: str) -> None:
2026-06-30 09:59:25 +00:00
"""Migrate every entry under `old_prefix/` to `new_prefix/`. Much
cheaper than a full rebuild microseconds of key swaps."""
old_prefix = old_prefix.rstrip("/")
new_prefix = new_prefix.rstrip("/")
if old_prefix == new_prefix:
return
with self._lock:
affected_paths = [p for p in self._notes if p == old_prefix or p.startswith(old_prefix + "/")]
for old_path in affected_paths:
2026-06-30 09:59:25 +00:00
suffix = old_path[len(old_prefix):]
self._rename_note_unlocked(old_path, new_prefix + suffix)
folders_to_rename = [
f for f in self._folders if f == old_prefix or f.startswith(old_prefix + "/")
]
for f in folders_to_rename:
self._folders.discard(f)
suffix = f[len(old_prefix):]
self._folders.add(new_prefix + suffix)
self._raw_fingerprint = None
self._built = False
def remove_folder_prefix(self, prefix: str) -> None:
2026-06-30 09:59:25 +00:00
"""Drop every entry under `prefix/`."""
prefix = prefix.rstrip("/")
with self._lock:
affected = [p for p in self._notes if p == prefix or p.startswith(prefix + "/")]
for path in affected:
self._remove_note_unlocked(path)
folders_to_drop = [
f for f in self._folders if f == prefix or f.startswith(prefix + "/")
]
for f in folders_to_drop:
self._folders.discard(f)
self._raw_fingerprint = None
# ------------------------------------------------------------------
# Read API — every method returns a snapshot copy
# ------------------------------------------------------------------
def get_backlink_candidate_sources(self, target_path: str) -> Set[str]:
"""Superset of true backlink sources. Combines strict resolved
backward links with the loose wikilink-token reverse index. Caller
runs the per-line matcher against each candidate to filter."""
target_lower = target_path.lower()
target_no_ext_lower = target_lower[:-3] if target_lower.endswith(".md") else target_lower
target_name = Path(target_path).stem.lower()
with self._lock:
candidates: Set[str] = set(self._links_backward.get(target_path, set()))
for key in (target_lower, target_no_ext_lower, target_name):
candidates.update(self._wikilink_tokens.get(key, set()))
candidates.discard(target_path)
return candidates
def get_graph_data(self) -> Tuple[List[str], List[Tuple[str, str, str]]]:
"""Snapshot of (sorted note paths, (source, target, type) edges)."""
with self._lock:
nodes = sorted(p for p, r in self._notes.items() if r.type == "note")
edges: List[Tuple[str, str, str]] = []
for src, targets in self._links_forward.items():
for target, edge_type in targets.items():
edges.append((src, target, edge_type))
return nodes, edges
def get_all_tags(self) -> Dict[str, int]:
2026-06-30 09:59:25 +00:00
"""Snapshot {tag: count}, sorted by tag name."""
with self._lock:
return {tag: len(paths) for tag, paths in sorted(self._tags_backward.items())}
def get_paths_for_tag(self, tag: str) -> Set[str]:
"""Snapshot set of paths tagged with `tag` (case-insensitive)."""
with self._lock:
return set(self._tags_backward.get(tag.lower(), set()))
def get_note_record(self, path: str) -> Optional[NoteRecord]:
with self._lock:
return self._notes.get(path)
2026-06-30 09:59:25 +00:00
def all_note_records(self) -> List[Tuple[str, NoteRecord]]:
"""Snapshot list of (path, record) for every indexed markdown note."""
with self._lock:
return [(p, r) for p, r in self._notes.items() if r.type == "note"]
def try_get_extraction(
self,
rel_path: str,
mtime: float,
) -> Optional[Tuple[List[str], Dict[str, List[str]]]]:
2026-06-30 09:59:25 +00:00
"""Return (tags, raw_links) from the index only if the recorded mtime
matches `mtime` exactly. Lets scan_notes_fast_walk skip the per-file
read on a warm scan."""
with self._lock:
rec = self._notes.get(rel_path)
if rec is None or rec.mtime != mtime:
return None
raw = self._raw_links.get(rel_path)
if raw is None:
return None
return (
list(rec.tags),
{
"wikilinks": list(raw.get("wikilinks", [])),
"mdlinks": list(raw.get("mdlinks", [])),
},
)
def get_search_candidates(self, query: str) -> Optional[Set[str]]:
2026-06-30 09:59:25 +00:00
"""Superset of paths whose content COULD contain `query`. Caller
still runs the substring match per candidate for confirmation +
snippet extraction. Returns None when the query is too short or
tokenizes to nothing caller should iterate every note instead."""
if not self._search_built:
return None
if len(query) < _SEARCH_MIN_QUERY_LEN:
return None
tokens = [m.group(0).lower() for m in _SEARCH_TOKEN_RE.finditer(query)]
if not tokens:
return None
with self._lock:
candidate = self._search_terms.get(tokens[0])
if candidate is None:
return set()
result: Set[str] = set(candidate)
for tok in tokens[1:]:
bucket = self._search_terms.get(tok)
if bucket is None:
return set()
result &= bucket
if not result:
break
return result
def stats(self) -> Dict[str, Any]:
2026-06-30 09:59:25 +00:00
"""Snapshot of counters + size metrics."""
with self._lock:
return {
"built": self._built,
"search_built": self._search_built,
"notes": len(self._notes),
"folders": len(self._folders),
"tags": len(self._tags_backward),
"links_forward_entries": len(self._links_forward),
"links_backward_entries": len(self._links_backward),
"wikilink_tokens": len(self._wikilink_tokens),
"search_terms": len(self._search_terms),
"counters": dict(self._stats),
}
# ==================================================================
# Internal — must hold _lock when called
# ==================================================================
def _rebuild_tags_unlocked(self) -> None:
self._tags_forward.clear()
self._tags_backward.clear()
for path, record in self._notes.items():
if record.type != "note":
continue
tags = record.tags
if not tags:
continue
self._tags_forward[path] = tags
for tag in tags:
self._tags_backward.setdefault(tag, set()).add(path)
def _rebuild_links_unlocked(self) -> None:
2026-06-30 09:59:25 +00:00
"""Full link re-resolution. Reuses one _Resolver across all sources
(O(N+K*L) instead of O(N*K))."""
self._links_forward.clear()
self._links_backward.clear()
self._wikilink_tokens.clear()
note_paths = {p for p, r in self._notes.items() if r.type == "note"}
resolver = _Resolver(note_paths)
for source_path in self._raw_links:
self._resolve_single_source_unlocked(source_path, resolver=resolver, skip_cleanup=True)
def _prune_search_unlocked(self) -> None:
2026-06-30 09:59:25 +00:00
"""Drop search-term entries for paths no longer in the index."""
live_paths = set(self._notes.keys())
empty_terms = []
for term, paths in self._search_terms.items():
stale = paths - live_paths
if stale:
paths -= stale
if not paths:
empty_terms.append(term)
for term in empty_terms:
del self._search_terms[term]
def _update_search_for_note_unlocked(self, path: str, content: str) -> None:
"""Replace one note's terms in the inverted index."""
empty_terms = []
for term, paths in self._search_terms.items():
if path in paths:
paths.discard(path)
if not paths:
empty_terms.append(term)
for term in empty_terms:
del self._search_terms[term]
for term in extract_search_terms(content):
self._search_terms.setdefault(term, set()).add(path)
def _resolve_single_source_unlocked(
self,
source_path: str,
resolver: Optional["_Resolver"] = None,
skip_cleanup: bool = False,
) -> None:
if not skip_cleanup:
old_targets = self._links_forward.pop(source_path, {})
for t in old_targets:
bucket = self._links_backward.get(t)
if bucket is not None:
bucket.discard(source_path)
if not bucket:
del self._links_backward[t]
empty_keys = []
for key, sources in self._wikilink_tokens.items():
sources.discard(source_path)
if not sources:
empty_keys.append(key)
for k in empty_keys:
del self._wikilink_tokens[k]
raw = self._raw_links.get(source_path)
if not raw:
return
if resolver is None:
note_paths = {p for p, r in self._notes.items() if r.type == "note"}
resolver = _Resolver(note_paths)
source_folder = str(Path(source_path).parent).replace("\\", "/")
if source_folder == ".":
source_folder = ""
targets: Dict[str, str] = {}
2026-06-30 09:59:25 +00:00
# Wikilinks first (they win the "first wins" dedup that /api/graph uses).
for target in raw.get("wikilinks", []):
resolved = resolver.resolve_wikilink(target, source_folder)
if resolved and resolved != source_path and resolved not in targets:
targets[resolved] = "wikilink"
2026-06-30 09:59:25 +00:00
# Loose token index — populated even when strict resolution fails,
# because backlink matching uses stem comparison independently.
t_lower = target.strip().lower()
if t_lower:
self._wikilink_tokens.setdefault(t_lower, set()).add(source_path)
t_no_ext = t_lower[:-3] if t_lower.endswith(".md") else t_lower
if t_no_ext != t_lower:
self._wikilink_tokens.setdefault(t_no_ext, set()).add(source_path)
for link_path in raw.get("mdlinks", []):
resolved = resolver.resolve_mdlink(link_path, source_folder)
if resolved and resolved != source_path and resolved not in targets:
targets[resolved] = "markdown"
if targets:
self._links_forward[source_path] = targets
for t in targets:
self._links_backward.setdefault(t, set()).add(source_path)
def _rename_note_unlocked(self, old_path: str, new_path: str) -> None:
2026-06-30 09:59:25 +00:00
"""rename_note() body without acquiring the lock — used by
rename_folder_prefix to avoid re-locking per file."""
if old_path == new_path:
return
old_record = self._notes.pop(old_path, None)
if old_record is None:
return
folder = str(Path(new_path).parent).replace("\\", "/")
new_record = NoteRecord(
path=new_path,
name=Path(new_path).stem,
folder="" if folder == "." else folder,
modified=old_record.modified,
size=old_record.size,
type=old_record.type,
mtime=old_record.mtime,
tags=old_record.tags,
)
self._notes[new_path] = new_record
if new_record.folder:
self._folders.add(new_record.folder)
if old_path in self._tags_forward:
tags = self._tags_forward.pop(old_path)
self._tags_forward[new_path] = tags
for t in tags:
bucket = self._tags_backward.get(t)
if bucket is not None:
bucket.discard(old_path)
bucket.add(new_path)
for paths in self._search_terms.values():
if old_path in paths:
paths.discard(old_path)
paths.add(new_path)
if old_path in self._raw_links:
self._raw_links[new_path] = self._raw_links.pop(old_path)
self._links_forward.pop(old_path, None)
for bucket in self._links_backward.values():
bucket.discard(old_path)
old_backlinks = self._links_backward.pop(old_path, set())
for src in old_backlinks:
fwd = self._links_forward.get(src)
if fwd is not None and old_path in fwd:
del fwd[old_path]
if not fwd:
del self._links_forward[src]
empty_keys = []
for key, sources in self._wikilink_tokens.items():
if old_path in sources:
sources.discard(old_path)
sources.add(new_path)
if not sources:
empty_keys.append(key)
for k in empty_keys:
del self._wikilink_tokens[k]
def _remove_note_unlocked(self, path: str) -> None:
"""Same as remove_note() but assumes the caller already holds the lock."""
old_record = self._notes.pop(path, None)
if old_record is None:
return
for t in old_record.tags:
bucket = self._tags_backward.get(t)
if bucket is not None:
bucket.discard(path)
if not bucket:
del self._tags_backward[t]
self._tags_forward.pop(path, None)
self._raw_links.pop(path, None)
old_targets = self._links_forward.pop(path, {})
for t in old_targets:
bucket = self._links_backward.get(t)
if bucket is not None:
bucket.discard(path)
if not bucket:
del self._links_backward[t]
empty_keys = []
for key, sources in self._wikilink_tokens.items():
sources.discard(path)
if not sources:
empty_keys.append(key)
for k in empty_keys:
del self._wikilink_tokens[k]
sources_pointing_here = self._links_backward.pop(path, set())
for src in sources_pointing_here:
fwd = self._links_forward.get(src)
if fwd is not None and path in fwd:
del fwd[path]
if not fwd:
del self._links_forward[src]
empty_terms = []
for term, paths in self._search_terms.items():
if path in paths:
paths.discard(path)
if not paths:
empty_terms.append(term)
for term in empty_terms:
del self._search_terms[term]
# ============================================================================
# Resolver — link-target matching (mirrors legacy /api/graph rules exactly)
# ============================================================================
class _Resolver:
2026-06-30 09:59:25 +00:00
"""Link-target lookup tables. Build once per resolution batch, then call
resolve_* repeatedly across many sources."""
def __init__(self, all_notes: Set[str]) -> None:
self.note_paths: Set[str] = set(all_notes)
self.note_paths_lower: Dict[str, str] = {}
self.note_names: Dict[str, str] = {}
for p in all_notes:
self.note_paths_lower[p.lower()] = p
if p.endswith(".md"):
self.note_paths_lower[p[:-3].lower()] = p
stem = Path(p).stem
self.note_names[stem.lower()] = p
self.note_names[Path(p).name.lower()] = p
def resolve_wikilink(self, target: str, source_folder: str) -> Optional[str]:
target = target.strip()
if not target:
return None
target_lower = target.lower()
# 1. Relative to source folder (only for bare names with no slash).
if source_folder and "/" not in target:
relative_path = f"{source_folder}/{target}"
relative_path_lower = relative_path.lower()
if relative_path in self.note_paths:
return relative_path if relative_path.endswith(".md") else relative_path + ".md"
if relative_path + ".md" in self.note_paths:
return relative_path + ".md"
if relative_path_lower in self.note_paths_lower:
return self.note_paths_lower[relative_path_lower]
if (relative_path_lower + ".md") in self.note_paths_lower:
return self.note_paths_lower[relative_path_lower + ".md"]
if target in self.note_paths:
return target if target.endswith(".md") else target + ".md"
if (target + ".md") in self.note_paths:
return target + ".md"
if target_lower in self.note_paths_lower:
return self.note_paths_lower[target_lower]
if (target_lower + ".md") in self.note_paths_lower:
return self.note_paths_lower[target_lower + ".md"]
if target_lower in self.note_names:
return self.note_names[target_lower]
return None
def resolve_mdlink(self, link_path: str, source_folder: str) -> Optional[str]:
if not link_path:
return None
link_path = link_path.split("#")[0]
if not link_path:
return None
link_path = urllib.parse.unquote(link_path)
if link_path.startswith("./"):
link_path = link_path[2:]
link_path_with_md = link_path if link_path.endswith(".md") else link_path + ".md"
if source_folder and not link_path.startswith("/"):
relative_path = f"{source_folder}/{link_path}"
relative_path_with_md = f"{source_folder}/{link_path_with_md}"
relative_path_lower = relative_path.lower()
relative_path_with_md_lower = relative_path_with_md.lower()
if relative_path in self.note_paths:
return relative_path if relative_path.endswith(".md") else relative_path + ".md"
if relative_path_with_md in self.note_paths:
return relative_path_with_md
if relative_path_lower in self.note_paths_lower:
return self.note_paths_lower[relative_path_lower]
if relative_path_with_md_lower in self.note_paths_lower:
return self.note_paths_lower[relative_path_with_md_lower]
link_path_lower = link_path.lower()
link_path_with_md_lower = link_path_with_md.lower()
if link_path in self.note_paths:
return link_path if link_path.endswith(".md") else link_path + ".md"
if link_path_with_md in self.note_paths:
return link_path_with_md
if link_path_lower in self.note_paths_lower:
return self.note_paths_lower[link_path_lower]
if link_path_with_md_lower in self.note_paths_lower:
return self.note_paths_lower[link_path_with_md_lower]
return None
# ============================================================================
# Internal helpers
# ============================================================================
def _fingerprint(
notes_meta: List[NoteRecord],
sources_raw: Dict[str, Dict[str, List[str]]],
) -> int:
2026-06-30 09:59:25 +00:00
"""Cheap hash of a scan result. Short-circuits bulk_set when unchanged."""
notes_fp = hash(frozenset((n.path, n.mtime) for n in notes_meta))
raw_items = (
(src, tuple(raw.get("wikilinks", [])), tuple(raw.get("mdlinks", [])))
for src, raw in sources_raw.items()
)
return hash((notes_fp, hash(frozenset(raw_items))))
# ============================================================================
2026-06-30 09:59:25 +00:00
# Module singleton + facade. Callers in utils.py / main.py go through these.
# ============================================================================
_index = NoteIndex()
def get_index() -> NoteIndex:
return _index
2026-06-30 09:59:25 +00:00
# --- Lifecycle hooks (one-line calls from utils.py mutators) ----------------
def on_note_saved(notes_dir: str, full_path: Path, content: str) -> None:
2026-06-30 09:59:25 +00:00
"""A note was created or updated on disk."""
try:
rel_path = full_path.relative_to(Path(notes_dir)).as_posix()
st = full_path.stat()
folder = str(Path(rel_path).parent).replace("\\", "/")
record = NoteRecord(
path=rel_path,
name=Path(rel_path).stem,
folder="" if folder == "." else folder,
modified=datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
size=st.st_size,
type="note",
mtime=st.st_mtime,
tags=tuple(_parse_tags_for_record(content)),
)
2026-06-30 09:59:25 +00:00
_index.update_note(record, extract_links_from_content(content), content=content)
except Exception as e:
print(f"note_index: on_note_saved failed for {full_path}: {e}")
def on_note_deleted(notes_dir: str, full_path: Path) -> None:
try:
rel_path = full_path.relative_to(Path(notes_dir)).as_posix()
_index.remove_note(rel_path)
except Exception as e:
print(f"note_index: on_note_deleted failed for {full_path}: {e}")
def on_note_renamed(notes_dir: str, old_full_path: Path, new_full_path: Path) -> None:
try:
base = Path(notes_dir)
_index.rename_note(
old_full_path.relative_to(base).as_posix(),
new_full_path.relative_to(base).as_posix(),
)
except Exception as e:
print(f"note_index: on_note_renamed failed: {e}")
def on_folder_renamed(notes_dir: str, old_full_path: Path, new_full_path: Path) -> None:
2026-06-30 09:59:25 +00:00
"""Re-key every entry under the folder. No disk reads."""
try:
base = Path(notes_dir)
_index.rename_folder_prefix(
old_full_path.relative_to(base).as_posix(),
new_full_path.relative_to(base).as_posix(),
)
except Exception as e:
print(f"note_index: on_folder_renamed failed: {e}")
2026-06-30 09:59:25 +00:00
_index.invalidate()
def on_folder_deleted(notes_dir: str, full_path: Path) -> None:
try:
rel_prefix = full_path.relative_to(Path(notes_dir)).as_posix()
_index.remove_folder_prefix(rel_prefix)
except Exception as e:
print(f"note_index: on_folder_deleted failed: {e}")
_index.invalidate()
def populate_from_scan(
notes_meta: List[NoteRecord],
folders: Iterable[str],
sources_raw: Dict[str, Dict[str, List[str]]],
) -> None:
2026-06-30 09:59:25 +00:00
"""Bulk-replace the index after a full scan_notes_fast_walk."""
try:
_index.bulk_set(notes_meta, folders, sources_raw)
except Exception as e:
print(f"note_index: populate_from_scan failed: {e}")
def ensure_search_index(notes_dir: str) -> bool:
2026-06-30 09:59:25 +00:00
"""Lazy-build the search index on first /api/search. Returns False only
if the main index isn't built yet."""
if not _index.is_built():
return False
return _index.ensure_search_index_built(notes_dir)
2026-06-30 09:59:25 +00:00
# --- Read facade -------------------------------------------------------------
2026-06-30 09:59:25 +00:00
def get_backlink_candidates(target_path: str) -> Set[str]:
return _index.get_backlink_candidate_sources(target_path)
2026-06-30 09:59:25 +00:00
def get_graph_data() -> Tuple[List[str], List[Tuple[str, str, str]]]:
return _index.get_graph_data()
2026-06-30 09:59:25 +00:00
def get_search_candidates(query: str) -> Optional[Set[str]]:
"""Returns None when query is too short / untokenizable — caller iterates
every indexed note instead."""
return _index.get_search_candidates(query)
2026-06-30 09:59:25 +00:00
def get_all_tags() -> Dict[str, int]:
return _index.get_all_tags()
2026-06-30 09:59:25 +00:00
def get_paths_for_tag(tag: str) -> Set[str]:
return _index.get_paths_for_tag(tag)
def try_get_extraction(
rel_path: str,
mtime: float,
) -> Optional[Tuple[List[str], Dict[str, List[str]]]]:
2026-06-30 09:59:25 +00:00
"""(tags, raw_links) from the index when mtime matches, else None so the
caller reads the file. Used by scan_notes_fast_walk."""
return _index.try_get_extraction(rel_path, mtime)
def stats() -> Dict[str, Any]:
return _index.stats()
2026-06-30 09:59:25 +00:00
# Late import — utils imports this module, so we delay the reverse direction.
def _parse_tags_for_record(content: str) -> List[str]:
from .utils import parse_tags
return parse_tags(content)