From e9b88516aeb835ef57156738b72233f4dc751fd1 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Tue, 30 Jun 2026 13:47:19 +0200 Subject: [PATCH] stats endpoint perf boost + bugfix --- backend/main.py | 57 ++++++++++++++----------------------------- backend/note_index.py | 32 ++++++++++++++++++++++++ backend/utils.py | 15 ++++++------ 3 files changed, 58 insertions(+), 46 deletions(-) diff --git a/backend/main.py b/backend/main.py index 5de81c7..5417fb6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -24,6 +24,7 @@ from slowapi.errors import RateLimitExceeded from .utils import ( scan_notes_fast_walk, + ensure_index_built, get_note_content, save_note, delete_note, @@ -1558,55 +1559,33 @@ async def get_index_stats(): @api_router.get("/stats", tags=["Stats"]) @limiter.limit("30/minute") async def get_stats(request: Request): - """ - Get application statistics at a glance. + """At-a-glance counts for dashboard widgets (Homepage etc.). - Designed for dashboard widgets (e.g., Homepage) - lightweight and cached. - Returns counts of notes, folders, tags, templates, media, and other metadata. - """ + All vault aggregates are read from the in-memory index — no file walk on + the request path. Templates / plugins / version are looked up directly.""" try: notes_dir = config['storage']['notes_dir'] - - # Get notes and folders (cached) - notes, folders = scan_notes_fast_walk(notes_dir, include_media=True) - - # Separate notes from media - note_items = [n for n in notes if n.get('type') == 'note'] - media_items = [n for n in notes if n.get('type') != 'note'] - - # Count unique tags - all_tags = set() - for note in note_items: - all_tags.update(note.get('tags', [])) - - # Get templates count - templates = get_templates(notes_dir) - - # Calculate total size - total_size = sum(n.get('size', 0) for n in notes) - - # Get last modified (notes are already sorted by modified desc) - last_modified = note_items[0].get('modified') if note_items else None - - # Count enabled plugins + ensure_index_built(notes_dir) + s = note_index.summary() + + templates_count = len(get_templates(notes_dir)) enabled_plugins = sum(1 for p in plugin_manager.plugins.values() if p.enabled) - - # Read version + version = "unknown" version_file = Path(__file__).parent.parent / "VERSION" if version_file.exists(): version = version_file.read_text().strip() - + return { - "notes_count": len(note_items), - "folders_count": len(folders), - "tags_count": len(all_tags), - "templates_count": len(templates), - "media_count": len(media_items), - "total_size_bytes": total_size, - "last_modified": last_modified, + "notes_count": s["notes_count"], + "folders_count": s["folders_count"], + "tags_count": s["tags_count"], + "templates_count": templates_count, + "media_count": s["media_count"], + "total_size_bytes": s["total_size_bytes"], + "last_modified": s["last_modified"], "plugins_enabled": enabled_plugins, - "version": version + "version": version, } except Exception as e: raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get stats")) diff --git a/backend/note_index.py b/backend/note_index.py index 42767c5..968b5ba 100644 --- a/backend/note_index.py +++ b/backend/note_index.py @@ -499,6 +499,34 @@ class NoteIndex: break return result + def summary(self) -> Dict[str, Any]: + """Aggregate counts + total size + last-modified note. Powers /api/stats + without a vault scan — every field is computed from records already in + memory.""" + with self._lock: + notes_count = 0 + media_count = 0 + total_size = 0 + last_modified: Optional[str] = None + last_mtime = -1.0 + for rec in self._notes.values(): + total_size += rec.size + if rec.type == "note": + notes_count += 1 + if rec.mtime > last_mtime: + last_mtime = rec.mtime + last_modified = rec.modified + else: + media_count += 1 + return { + "notes_count": notes_count, + "media_count": media_count, + "folders_count": len(self._folders), + "tags_count": len(self._tags_backward), + "total_size_bytes": total_size, + "last_modified": last_modified, + } + def stats(self) -> Dict[str, Any]: """Snapshot of counters + size metrics.""" with self._lock: @@ -968,6 +996,10 @@ def try_get_extraction( return _index.try_get_extraction(rel_path, mtime) +def summary() -> Dict[str, Any]: + return _index.summary() + + def stats() -> Dict[str, Any]: return _index.stats() diff --git a/backend/utils.py b/backend/utils.py index 5061f9d..2bf30c6 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -167,11 +167,12 @@ def _scan_cache_invalidate() -> None: _SCAN_WALK_CACHE.clear() -def _ensure_index_built(notes_dir: str) -> None: +def ensure_index_built(notes_dir: str) -> None: """Trigger a fresh scan when the NoteIndex hasn't been populated yet. - Idempotent: the scan short-circuits when nothing changed in the vault.""" + Scans with include_media=True so every index consumer (stats, notes, + backlinks, search) shares one fingerprint and never thrashes.""" if not note_index.get_index().is_built(): - scan_notes_fast_walk(notes_dir, use_cache=False, include_media=False) + scan_notes_fast_walk(notes_dir, use_cache=False, include_media=True) def get_tags_and_links_cached( @@ -545,7 +546,7 @@ def search_notes(notes_dir: str, query: str) -> List[Dict]: from html import escape results: List[Dict] = [] - _ensure_index_built(notes_dir) + ensure_index_built(notes_dir) note_index.ensure_search_index(notes_dir) candidates = note_index.get_search_candidates(query) @@ -875,13 +876,13 @@ def parse_tags(content: str) -> List[str]: def get_all_tags(notes_dir: str) -> Dict[str, int]: """All tags in the vault with note counts.""" - _ensure_index_built(notes_dir) + ensure_index_built(notes_dir) return note_index.get_all_tags() def get_notes_by_tag(notes_dir: str, tag: str) -> List[Dict]: """All notes carrying `tag` (case-insensitive).""" - _ensure_index_built(notes_dir) + ensure_index_built(notes_dir) idx = note_index.get_index() records = [idx.get_note_record(p) for p in note_index.get_paths_for_tag(tag.lower())] matching = [ @@ -1151,7 +1152,7 @@ def get_backlinks(notes_dir: str, target_note_path: str) -> List[Dict]: Path(target_path).stem.lower(), } - _ensure_index_built(notes_dir) + ensure_index_built(notes_dir) idx = note_index.get_index() candidates = note_index.get_backlink_candidates(target_path) records = [(p, idx.get_note_record(p)) for p in candidates]