Merge pull request #245 from gamosoft/features/global-index

Features/global index
This commit is contained in:
Guillermo Villar 2026-06-30 17:51:20 +02:00 committed by GitHub
commit b4952df6be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 2129 additions and 1086 deletions

View File

@ -8,6 +8,7 @@ replaced with placeholder HTML since they would make exports too large.
""" """
import base64 import base64
import logging
import re import re
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple from typing import Optional, Tuple
@ -16,6 +17,8 @@ import mimetypes
# Import shared media type definitions and scanner from utils to avoid duplication # Import shared media type definitions and scanner from utils to avoid duplication
from backend.utils import MEDIA_EXTENSIONS, get_media_type, scan_notes_fast_walk from backend.utils import MEDIA_EXTENSIONS, get_media_type, scan_notes_fast_walk
logger = logging.getLogger("uvicorn.error")
def get_media_as_base64(media_path: Path) -> Optional[Tuple[str, str]]: def get_media_as_base64(media_path: Path) -> Optional[Tuple[str, str]]:
""" """
@ -41,7 +44,7 @@ def get_media_as_base64(media_path: Path) -> Optional[Tuple[str, str]]:
base64_data = base64.b64encode(media_data).decode('utf-8') base64_data = base64.b64encode(media_data).decode('utf-8')
return (f"data:{mime_type};base64,{base64_data}", media_type) return (f"data:{mime_type};base64,{base64_data}", media_type)
except Exception as e: except Exception as e:
print(f"Failed to read media {media_path}: {e}") logger.error("Failed to read media %s: %s", media_path, e)
return None return None

View File

@ -12,6 +12,7 @@ from starlette.middleware.sessions import SessionMiddleware
import os import os
import yaml import yaml
import json import json
import logging
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
import aiofiles import aiofiles
@ -22,8 +23,11 @@ from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
logger = logging.getLogger("uvicorn.error")
from .utils import ( from .utils import (
scan_notes_fast_walk, scan_notes_fast_walk,
ensure_index_built,
get_note_content, get_note_content,
save_note, save_note,
delete_note, delete_note,
@ -36,6 +40,7 @@ from .utils import (
rename_folder, rename_folder,
delete_folder, delete_folder,
save_uploaded_image, save_uploaded_image,
_scan_cache_invalidate,
validate_path_security, validate_path_security,
get_all_tags, get_all_tags,
get_notes_by_tag, get_notes_by_tag,
@ -45,6 +50,7 @@ from .utils import (
paginate, paginate,
get_backlinks, get_backlinks,
) )
from . import note_index
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
from .share import ( from .share import (
@ -77,9 +83,9 @@ with open(version_path, 'r', encoding='utf-8') as f:
if 'AUTHENTICATION_ENABLED' in os.environ: if 'AUTHENTICATION_ENABLED' in os.environ:
auth_enabled = os.getenv('AUTHENTICATION_ENABLED', 'false').lower() in ('true', '1', 'yes') auth_enabled = os.getenv('AUTHENTICATION_ENABLED', 'false').lower() in ('true', '1', 'yes')
config['authentication']['enabled'] = auth_enabled config['authentication']['enabled'] = auth_enabled
print(f"🔐 Authentication {'ENABLED' if auth_enabled else 'DISABLED'} (from AUTHENTICATION_ENABLED env var)") logger.info("Authentication %s (from AUTHENTICATION_ENABLED env var)", 'ENABLED' if auth_enabled else 'DISABLED')
else: else:
print(f"🔐 Authentication {'ENABLED' if config.get('authentication', {}).get('enabled', False) else 'DISABLED'} (from config.yaml)") logger.info("Authentication %s (from config.yaml)", 'ENABLED' if config.get('authentication', {}).get('enabled', False) else 'DISABLED')
# Password configuration priority: # Password configuration priority:
# 1. AUTHENTICATION_PASSWORD env var (hashed at startup) # 1. AUTHENTICATION_PASSWORD env var (hashed at startup)
@ -92,9 +98,9 @@ if 'AUTHENTICATION_PASSWORD' in os.environ:
plain_password.encode('utf-8'), plain_password.encode('utf-8'),
bcrypt.gensalt() bcrypt.gensalt()
).decode('utf-8') ).decode('utf-8')
print("🔑 Password loaded from AUTHENTICATION_PASSWORD env var") logger.info("Password loaded from AUTHENTICATION_PASSWORD env var")
else: else:
print("⚠️ WARNING: AUTHENTICATION_PASSWORD env var is empty - ignoring") logger.warning("AUTHENTICATION_PASSWORD env var is empty - ignoring")
elif config.get('authentication', {}).get('password', '').strip(): elif config.get('authentication', {}).get('password', '').strip():
plain_password = config['authentication']['password'].strip() plain_password = config['authentication']['password'].strip()
config['authentication']['password_hash'] = bcrypt.hashpw( config['authentication']['password_hash'] = bcrypt.hashpw(
@ -102,12 +108,12 @@ elif config.get('authentication', {}).get('password', '').strip():
bcrypt.gensalt() bcrypt.gensalt()
).decode('utf-8') ).decode('utf-8')
del config['authentication']['password'] del config['authentication']['password']
print("🔑 Password loaded from config.yaml") logger.info("Password loaded from config.yaml")
# Allow secret key to be set via environment variable (for session security) # Allow secret key to be set via environment variable (for session security)
if 'AUTHENTICATION_SECRET_KEY' in os.environ: if 'AUTHENTICATION_SECRET_KEY' in os.environ:
config['authentication']['secret_key'] = os.getenv('AUTHENTICATION_SECRET_KEY') config['authentication']['secret_key'] = os.getenv('AUTHENTICATION_SECRET_KEY')
print("🔐 Secret key loaded from AUTHENTICATION_SECRET_KEY env var") logger.info("Secret key loaded from AUTHENTICATION_SECRET_KEY env var")
# API key configuration for external integrations (MCP servers, scripts, etc.) # API key configuration for external integrations (MCP servers, scripts, etc.)
# Priority: AUTHENTICATION_API_KEY env var > authentication.api_key in config.yaml # Priority: AUTHENTICATION_API_KEY env var > authentication.api_key in config.yaml
@ -115,11 +121,11 @@ if 'AUTHENTICATION_API_KEY' in os.environ:
api_key_value = os.getenv('AUTHENTICATION_API_KEY', '').strip() api_key_value = os.getenv('AUTHENTICATION_API_KEY', '').strip()
if api_key_value: if api_key_value:
config['authentication']['api_key'] = api_key_value config['authentication']['api_key'] = api_key_value
print("🔑 API key loaded from AUTHENTICATION_API_KEY env var") logger.info("API key loaded from AUTHENTICATION_API_KEY env var")
else: else:
config['authentication']['api_key'] = '' config['authentication']['api_key'] = ''
elif config.get('authentication', {}).get('api_key', '').strip(): elif config.get('authentication', {}).get('api_key', '').strip():
print("🔑 API key loaded from config.yaml") logger.info("API key loaded from config.yaml")
else: else:
config['authentication']['api_key'] = '' config['authentication']['api_key'] = ''
@ -131,15 +137,29 @@ if config.get('authentication', {}).get('enabled', False):
_is_default_secret = _secret_key in ('', 'change_this_to_a_random_secret_key_in_production') _is_default_secret = _secret_key in ('', 'change_this_to_a_random_secret_key_in_production')
if not _has_password and not _has_api_key: if not _has_password and not _has_api_key:
print("🚨 CRITICAL: Authentication enabled but NO auth methods configured - ALL access will be denied!") logger.critical("Authentication enabled but NO auth methods configured - ALL access will be denied!")
else: else:
if not _has_password: if not _has_password:
print("⚠️ WARNING: No password configured - web UI login will not work") logger.warning("No password configured - web UI login will not work")
if not _has_api_key: if not _has_api_key:
print("⚠️ WARNING: No API key configured - external integrations will require session cookies") logger.warning("No API key configured - external integrations will require session cookies")
if _is_default_secret: if _is_default_secret:
print("🚨 SECURITY WARNING: Using default secret_key - sessions can be forged! Change it in config.yaml") logger.critical("Using default secret_key - sessions can be forged! Change it in config.yaml")
# Storage paths: env vars override config.yaml. Logged either way so the
# resolved location is visible at startup.
_notes_source = "config.yaml"
if 'NOTES_DIR' in os.environ:
config['storage']['notes_dir'] = os.getenv('NOTES_DIR')
_notes_source = "NOTES_DIR env var"
logger.info("Notes directory: %s (from %s)", config['storage']['notes_dir'], _notes_source)
_plugins_source = "config.yaml"
if 'PLUGINS_DIR' in os.environ:
config['storage']['plugins_dir'] = os.getenv('PLUGINS_DIR')
_plugins_source = "PLUGINS_DIR env var"
logger.info("Plugins directory: %s (from %s)", config['storage']['plugins_dir'], _plugins_source)
# OpenAPI tag metadata for grouping endpoints in Swagger UI # OpenAPI tag metadata for grouping endpoints in Swagger UI
tags_metadata = [ tags_metadata = [
@ -176,7 +196,7 @@ app.add_middleware(
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
print(f"🌐 CORS allowed origins: {allowed_origins}") logger.info("CORS allowed origins: %s", allowed_origins)
# =========================================================== # ===========================================================
# ================= # =================
@ -199,7 +219,7 @@ def safe_error_message(error: Exception, user_message: str = "An error occurred"
error_details = f"{type(error).__name__}: {str(error)}" error_details = f"{type(error).__name__}: {str(error)}"
# Always log the full error server-side # Always log the full error server-side
print(f"⚠️ [ERROR] {error_details}") logger.error(error_details)
# In debug mode, return detailed error to help with development # In debug mode, return detailed error to help with development
if config.get('server', {}).get('debug', False): if config.get('server', {}).get('debug', False):
@ -245,7 +265,7 @@ if DEMO_MODE:
limiter = Limiter(key_func=get_remote_address, default_limits=["200/hour"]) limiter = Limiter(key_func=get_remote_address, default_limits=["200/hour"])
app.state.limiter = limiter app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
print("🎭 DEMO MODE enabled - Rate limiting active") logger.info("DEMO MODE enabled - Rate limiting active")
else: else:
# Production/self-hosted mode - no restrictions # Production/self-hosted mode - no restrictions
# Create a dummy limiter that doesn't actually limit # Create a dummy limiter that doesn't actually limit
@ -265,6 +285,7 @@ plugin_manager = PluginManager(config['storage']['plugins_dir'])
# Run app startup hooks # Run app startup hooks
plugin_manager.run_hook('on_app_startup') plugin_manager.run_hook('on_app_startup')
# Mount static files # Mount static files
static_path = Path(__file__).parent.parent / "frontend" static_path = Path(__file__).parent.parent / "frontend"
app.mount("/static", StaticFiles(directory=static_path), name="static") app.mount("/static", StaticFiles(directory=static_path), name="static")
@ -413,7 +434,7 @@ def verify_password(password: str) -> bool:
try: try:
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8')) return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
except Exception as e: except Exception as e:
print(f"Password verification error: {e}") logger.error("Password verification error: %s", e)
return False return False
@ -841,6 +862,7 @@ async def move_media_endpoint(request: Request, data: dict):
# Move the file # Move the file
import shutil import shutil
shutil.move(str(old_full_path), str(new_full_path)) shutil.move(str(old_full_path), str(new_full_path))
_scan_cache_invalidate()
return {"success": True, "message": "Media moved successfully", "newPath": new_path} return {"success": True, "message": "Media moved successfully", "newPath": new_path}
@ -1483,171 +1505,15 @@ async def search(
@api_router.get("/graph", tags=["Graph"]) @api_router.get("/graph", tags=["Graph"])
async def get_graph(): async def get_graph():
"""Get graph data for note visualization with wikilink and markdown link detection""" """Graph data (nodes + resolved wikilink/markdown edges) for the visualizer."""
try: try:
import re if not note_index.get_index().is_built():
import urllib.parse scan_notes_fast_walk(config['storage']['notes_dir'], include_media=False)
notes, _folders = scan_notes_fast_walk(config['storage']['notes_dir'], include_media=False) nodes_paths, edges_tuples = note_index.get_graph_data()
nodes = [] return {
edges = [] "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],
# Build set of valid note names/paths for matching }
note_paths = set()
note_paths_lower = {} # Map lowercase path -> actual path for case-insensitive matching
note_names = {} # Map name -> path for quick lookup
for note in notes:
if note.get('type') == 'note':
note_paths.add(note['path'])
note_paths.add(note['path'].replace('.md', ''))
# Store lowercase path -> actual path mapping for case-insensitive matching
note_paths_lower[note['path'].lower()] = note['path']
note_paths_lower[note['path'].replace('.md', '').lower()] = note['path']
# Store name -> path mapping (without extension)
name = note['name'].replace('.md', '')
note_names[name.lower()] = note['path']
note_names[note['name'].lower()] = note['path']
# Build graph structure with link detection
for note in notes:
if note.get('type') == 'note':
nodes.append({
"id": note['path'],
"label": note['name'].replace('.md', '')
})
# Read note content to find links
content = get_note_content(config['storage']['notes_dir'], note['path'])
if content:
# Find wikilinks: [[target]] or [[target|display]]
wikilinks = re.findall(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]', content)
# Find standard markdown internal links: [text](path) - any local path (not http/https)
# Match links that don't start with http://, https://, mailto:, #, etc.
markdown_links = re.findall(r'\[([^\]]+)\]\((?!https?://|mailto:|#|data:)([^\)]+)\)', content)
# Get source note's folder for resolving relative links
# Use forward slashes consistently (note_paths uses forward slashes)
source_folder = str(Path(note['path']).parent).replace('\\', '/')
if source_folder == '.':
source_folder = ''
# Process wikilinks
for target in wikilinks:
target = target.strip()
target_lower = target.lower()
# Try to match target to an existing note
target_path = None
# 1. Try resolving relative to source note's folder first
if source_folder and '/' not in target:
relative_path = f"{source_folder}/{target}"
relative_path_lower = relative_path.lower()
if relative_path in note_paths:
target_path = relative_path if relative_path.endswith('.md') else relative_path + '.md'
elif relative_path + '.md' in note_paths:
target_path = relative_path + '.md'
elif relative_path_lower in note_paths_lower:
target_path = note_paths_lower[relative_path_lower]
elif relative_path_lower + '.md' in note_paths_lower:
target_path = note_paths_lower[relative_path_lower + '.md']
# 2. Exact path match (absolute or already has folder)
if not target_path:
if target in note_paths:
target_path = target if target.endswith('.md') else target + '.md'
elif target + '.md' in note_paths:
target_path = target + '.md'
# 3. Case-insensitive path match (e.g., [[Folder/Note]] -> folder/note.md)
elif target_lower in note_paths_lower:
target_path = note_paths_lower[target_lower]
elif target_lower + '.md' in note_paths_lower:
target_path = note_paths_lower[target_lower + '.md']
# 4. Just note name (case-insensitive) - global match
elif target_lower in note_names:
target_path = note_names[target_lower]
if target_path and target_path != note['path']:
edges.append({
"source": note['path'],
"target": target_path,
"type": "wikilink"
})
# Process markdown links
for _, link_path in markdown_links:
# Skip anchor-only links and external protocols
if not link_path or link_path.startswith('#'):
continue
# Remove anchor part if present (e.g., "note.md#section" -> "note.md")
link_path = link_path.split('#')[0]
if not link_path:
continue
# Normalize path: remove ./ prefix, handle URL encoding
link_path = urllib.parse.unquote(link_path)
if link_path.startswith('./'):
link_path = link_path[2:]
# Add .md extension if not present and doesn't have other extension
link_path_with_md = link_path if link_path.endswith('.md') else link_path + '.md'
# Try to match target to an existing note
target_path = None
# 1. First, try resolving relative to source note's folder
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 note_paths:
target_path = relative_path if relative_path.endswith('.md') else relative_path + '.md'
elif relative_path_with_md in note_paths:
target_path = relative_path_with_md
elif relative_path_lower in note_paths_lower:
target_path = note_paths_lower[relative_path_lower]
elif relative_path_with_md_lower in note_paths_lower:
target_path = note_paths_lower[relative_path_with_md_lower]
# 2. Try exact path match from root (for absolute paths or notes at root)
if not target_path:
link_path_lower = link_path.lower()
link_path_with_md_lower = link_path_with_md.lower()
if link_path in note_paths:
target_path = link_path if link_path.endswith('.md') else link_path + '.md'
elif link_path_with_md in note_paths:
target_path = link_path_with_md
# Case-insensitive path match
elif link_path_lower in note_paths_lower:
target_path = note_paths_lower[link_path_lower]
elif link_path_with_md_lower in note_paths_lower:
target_path = note_paths_lower[link_path_with_md_lower]
# No global filename fallback for markdown links - they must resolve as paths
if target_path and target_path != note['path']:
edges.append({
"source": note['path'],
"target": target_path,
"type": "markdown"
})
# Remove duplicate edges
seen = set()
unique_edges = []
for edge in edges:
key = (edge['source'], edge['target'])
if key not in seen:
seen.add(key)
unique_edges.append(edge)
return {"nodes": nodes, "edges": unique_edges}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to generate graph data")) raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to generate graph data"))
@ -1692,6 +1558,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")) 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) # Stats Endpoint (for dashboards)
# ============================================================================ # ============================================================================
@ -1699,55 +1578,33 @@ async def toggle_plugin(request: Request, plugin_name: str, enabled: dict):
@api_router.get("/stats", tags=["Stats"]) @api_router.get("/stats", tags=["Stats"])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def get_stats(request: Request): async def get_stats(request: Request):
""" """At-a-glance counts for dashboard widgets (Homepage etc.).
Get application statistics at a glance.
Designed for dashboard widgets (e.g., Homepage) - lightweight and cached. All vault aggregates are read from the in-memory index no file walk on
Returns counts of notes, folders, tags, templates, media, and other metadata. the request path. Templates / plugins / version are looked up directly."""
"""
try: try:
notes_dir = config['storage']['notes_dir'] notes_dir = config['storage']['notes_dir']
ensure_index_built(notes_dir)
s = note_index.summary()
# Get notes and folders (cached) templates_count = len(get_templates(notes_dir))
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
enabled_plugins = sum(1 for p in plugin_manager.plugins.values() if p.enabled) enabled_plugins = sum(1 for p in plugin_manager.plugins.values() if p.enabled)
# Read version
version = "unknown" version = "unknown"
version_file = Path(__file__).parent.parent / "VERSION" version_file = Path(__file__).parent.parent / "VERSION"
if version_file.exists(): if version_file.exists():
version = version_file.read_text().strip() version = version_file.read_text().strip()
return { return {
"notes_count": len(note_items), "notes_count": s["notes_count"],
"folders_count": len(folders), "folders_count": s["folders_count"],
"tags_count": len(all_tags), "tags_count": s["tags_count"],
"templates_count": len(templates), "templates_count": templates_count,
"media_count": len(media_items), "media_count": s["media_count"],
"total_size_bytes": total_size, "total_size_bytes": s["total_size_bytes"],
"last_modified": last_modified, "last_modified": s["last_modified"],
"plugins_enabled": enabled_plugins, "plugins_enabled": enabled_plugins,
"version": version "version": version,
} }
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get stats")) raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get stats"))
@ -1985,6 +1842,26 @@ app.include_router(api_router)
app.include_router(pages_router) app.include_router(pages_router)
# ============================================================================
# Startup warmup
# ============================================================================
# Pre-build the note index off the request path. Mid-warmup requests are safe
# (bulk_set is serialized and short-circuits on the fingerprint).
# Success is logged from inside bulk_set so we get a single line for both
# the initial build and any subsequent rebuilds triggered by external changes.
@app.on_event("startup")
def _warmup_note_index() -> None:
import threading
def _build() -> None:
try:
ensure_index_built(config['storage']['notes_dir'])
except Exception as exc:
logger.warning("Vault index rebuild failed (will retry on first request): %s", exc)
threading.Thread(target=_build, name="note-index-warmup", daemon=True).start()
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run( uvicorn.run(

1016
backend/note_index.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -5,10 +5,13 @@ Plugins can hook into events like note save, delete, etc.
import os import os
import json import json
import logging
import importlib.util import importlib.util
from pathlib import Path from pathlib import Path
from typing import List, Dict, Callable from typing import List, Dict, Callable
logger = logging.getLogger("uvicorn.error")
class Plugin: class Plugin:
"""Base plugin class""" """Base plugin class"""
@ -113,7 +116,7 @@ class PluginManager:
plugin = module.Plugin() plugin = module.Plugin()
self.plugins[plugin_file.stem] = plugin self.plugins[plugin_file.stem] = plugin
except Exception as e: except Exception as e:
print(f"Failed to load plugin {plugin_file.stem}: {e}") logger.error("Failed to load plugin %s: %s", plugin_file.stem, e)
def _create_example_plugin(self): def _create_example_plugin(self):
"""Create an example plugin to show developers how to build plugins""" """Create an example plugin to show developers how to build plugins"""
@ -167,7 +170,7 @@ class Plugin:
with open(self.config_file, 'r', encoding='utf-8') as f: with open(self.config_file, 'r', encoding='utf-8') as f:
return json.load(f) return json.load(f)
except Exception as e: except Exception as e:
print(f"Failed to load plugin config: {e}") logger.error("Failed to load plugin config: %s", e)
return {} return {}
def _save_config(self): def _save_config(self):
@ -180,7 +183,7 @@ class Plugin:
with open(self.config_file, 'w', encoding='utf-8') as f: with open(self.config_file, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=2) json.dump(config, f, indent=2)
except Exception as e: except Exception as e:
print(f"Failed to save plugin config: {e}") logger.error("Failed to save plugin config: %s", e)
def _apply_saved_state(self): def _apply_saved_state(self):
"""Apply saved plugin states after loading plugins""" """Apply saved plugin states after loading plugins"""
@ -188,7 +191,7 @@ class Plugin:
for plugin_id, enabled in saved_config.items(): for plugin_id, enabled in saved_config.items():
if plugin_id in self.plugins: if plugin_id in self.plugins:
self.plugins[plugin_id].enabled = enabled self.plugins[plugin_id].enabled = enabled
print(f"Plugin '{plugin_id}': {'enabled' if enabled else 'disabled'} (from config)") logger.info("Plugin '%s': %s (from config)", plugin_id, 'enabled' if enabled else 'disabled')
def enable_plugin(self, plugin_id: str): def enable_plugin(self, plugin_id: str):
"""Enable a plugin and persist the state""" """Enable a plugin and persist the state"""
@ -238,7 +241,7 @@ class Plugin:
method(**kwargs) method(**kwargs)
except Exception as e: except Exception as e:
print(f"Plugin {plugin.name} error in {hook_name}: {e}") logger.error("Plugin %s error in %s: %s", plugin.name, hook_name, e)
return result if 'content' in kwargs else None return result if 'content' in kwargs else None
@ -266,7 +269,7 @@ class Plugin:
if 'initial_content' in kwargs and result is not None: if 'initial_content' in kwargs and result is not None:
kwargs['initial_content'] = result kwargs['initial_content'] = result
except Exception as e: except Exception as e:
print(f"Plugin {plugin.name} error in {hook_name}: {e}") logger.error("Plugin %s error in %s: %s", plugin.name, hook_name, e)
# Return the final modified value # Return the final modified value
return kwargs.get('initial_content', '') return kwargs.get('initial_content', '')

View File

@ -4,6 +4,7 @@ Handles creating, storing, and revoking share tokens for public note access.
""" """
import json import json
import logging
import secrets import secrets
import string import string
from pathlib import Path from pathlib import Path
@ -13,6 +14,8 @@ import threading
from .utils import validate_path_security from .utils import validate_path_security
logger = logging.getLogger("uvicorn.error")
# Thread lock for safe concurrent access # Thread lock for safe concurrent access
_lock = threading.Lock() _lock = threading.Lock()
@ -99,7 +102,7 @@ def save_tokens(data_dir: str, tokens: Dict[str, Dict[str, Any]]) -> bool:
json.dump(tokens, f, indent=2, ensure_ascii=False) json.dump(tokens, f, indent=2, ensure_ascii=False)
return True return True
except IOError as e: except IOError as e:
print(f"Failed to save share tokens: {e}") logger.error("Failed to save share tokens: %s", e)
return False return False

View File

@ -2,10 +2,13 @@
Theme management for NoteDiscovery Theme management for NoteDiscovery
""" """
import logging
from pathlib import Path from pathlib import Path
from typing import List, Dict from typing import List, Dict
import re import re
logger = logging.getLogger("uvicorn.error")
def parse_theme_metadata(theme_path: Path) -> Dict[str, str]: def parse_theme_metadata(theme_path: Path) -> Dict[str, str]:
"""Parse theme metadata from CSS file comments""" """Parse theme metadata from CSS file comments"""
@ -28,7 +31,7 @@ def parse_theme_metadata(theme_path: Path) -> Dict[str, str]:
metadata["type"] = match.group(1) metadata["type"] = match.group(1)
break break
except Exception as e: except Exception as e:
print(f"Error parsing theme metadata from {theme_path}: {e}") logger.error("Error parsing theme metadata from %s: %s", theme_path, e)
return metadata return metadata

File diff suppressed because it is too large Load Diff

View File

@ -580,6 +580,57 @@ Returns application statistics at a glance. Designed for dashboard widgets (e.g.
label: Version 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
{
"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 |
|-------|-------------|
| `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 ### Health Check
```http ```http
GET /health GET /health

View File

@ -12,6 +12,30 @@ NoteDiscovery supports environment variables to override configuration settings,
> **Note**: Advanced server settings (CORS origins, debug mode) are configured via `config.yaml` only, not via environment variables. See [config.yaml](#advanced-server-configuration) for details. > **Note**: Advanced server settings (CORS origins, debug mode) are configured via `config.yaml` only, not via environment variables. See [config.yaml](#advanced-server-configuration) for details.
### Storage
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `NOTES_DIR` | string | `./data` | Path to the notes vault |
| `PLUGINS_DIR` | string | `./plugins` | Path to the plugins directory |
The resolved paths are logged at startup so you can confirm what's in use:
```
INFO: Notes directory: /home/me/MyVault (from NOTES_DIR env var)
INFO: Plugins directory: ./plugins (from config.yaml)
```
#### Example: Pointing at an existing vault
```bash
# Local
NOTES_DIR=/home/me/MyVault python run.py
# Docker
docker run -e NOTES_DIR=/vault -v /home/me/MyVault:/vault ...
```
### Authentication ### Authentication
| Variable | Type | Default | Description | | Variable | Type | Default | Description |

View File

@ -103,19 +103,25 @@ When a note is saved, the plugin:
3. Click to expand/collapse the stats panel 3. Click to expand/collapse the stats panel
4. Statistics update in real-time as you type 4. Statistics update in real-time as you type
### In Docker Logs ### In Server Logs
```bash ```bash
docker-compose logs -f | grep "📊" # Docker (any host OS)
docker-compose logs -f | grep "note_stats"
# Running locally (Linux / macOS)
python run.py 2>&1 | grep "note_stats"
# Running locally (Windows / PowerShell)
python run.py 2>&1 | findstr "note_stats"
``` ```
Example output: Example output (single line, prefixed with uvicorn's `INFO:`):
``` ```
📊 projects/website.md: INFO: note_stats projects/website.md | 1,234 words | 6 sentences | ~6m read | 89 lines | 15 links (5 internal) | 8/12 tasks
1,234 words | 6m read | 89 lines
15 links (5 internal)
8/12 tasks completed
``` ```
Optional sections (`lists`, `tables`, `links`, `tasks`) only appear when their count is non-zero.
--- ---
## Configuration ## Configuration
@ -124,11 +130,11 @@ No configuration needed. The plugin works out of the box.
### Customization (Optional) ### Customization (Optional)
To change reading speed calculation, edit `note_stats.py`: To change the reading-speed assumption used for `reading_time_minutes`,
edit the constant near the top of `note_stats.py`:
```python ```python
# Line 36 WORDS_PER_MINUTE = 200 # Change to your average reading speed
words_per_minute = 200 # Change to your reading speed
``` ```
--- ---

View File

@ -262,6 +262,13 @@ function noteApp() {
alreadyDonated: false, alreadyDonated: false,
autosaveDelayMs: CONFIG.AUTOSAVE_DELAY, // hydrated from /api/config in loadConfig() autosaveDelayMs: CONFIG.AUTOSAVE_DELAY, // hydrated from /api/config in loadConfig()
notes: [], notes: [],
// True while /api/notes is in flight. Drives the "Loading your vault…"
// placeholder + the delayed overlay (notesLoadingShowOverlay).
notesLoading: true,
notesLoadingShowOverlay: false,
_notesLoadingOverlayTimer: null,
currentNote: '', currentNote: '',
currentNoteName: '', currentNoteName: '',
noteContent: '', noteContent: '',
@ -1522,18 +1529,32 @@ function noteApp() {
// ==================== END INTERNATIONALIZATION ==================== // ==================== END INTERNATIONALIZATION ====================
// Load all notes // Load all notes. Pass {silent: true} from error-recovery paths so the
async loadNotes() { // 800ms loading overlay never appears on background re-syncs.
async loadNotes({ silent = false } = {}) {
this.notesLoading = true;
clearTimeout(this._notesLoadingOverlayTimer);
if (!silent) {
this._notesLoadingOverlayTimer = setTimeout(() => {
if (this.notesLoading && this.notes.length === 0 && this.allFolders.length === 0) {
this.notesLoadingShowOverlay = true;
}
}, 800);
}
try { try {
const response = await fetch('/api/notes'); const response = await fetch('/api/notes');
const data = await response.json(); const data = await response.json();
this.notes = data.notes; this.notes = data.notes;
this.allFolders = data.folders || []; this.allFolders = data.folders || [];
this.buildNoteLookupMaps(); // Build O(1) lookup maps this.buildNoteLookupMaps();
this.buildFolderTree(); this.buildFolderTree();
await this.loadTags(); // Load tags after notes are loaded await this.loadTags();
} catch (error) { } catch (error) {
ErrorHandler.handle('load notes', error); ErrorHandler.handle('load notes', error);
} finally {
clearTimeout(this._notesLoadingOverlayTimer);
this.notesLoading = false;
this.notesLoadingShowOverlay = false;
} }
}, },
@ -1738,37 +1759,43 @@ function noteApp() {
return; return;
} }
// Create note from template // Optimistic stub: add empty entry so sidebar updates instantly.
const response = await fetch('/api/templates/create-note', { // Server rendering may inject content/tags; loadNote() fetches the
method: 'POST', // real body and tags are refreshed via loadTagsDebounced below.
headers: { 'Content-Type': 'application/json' }, this._optimisticAddNote(notePath, { content: '' });
body: JSON.stringify({ const folderPart = notePath.includes('/') ? notePath.substring(0, notePath.lastIndexOf('/')) : '';
templateName: this.selectedTemplate, if (folderPart) this.expandedFolders.add(folderPart);
notePath: notePath this._rebuildTreeAfterMutation();
})
});
if (!response.ok) { try {
const error = await response.json(); const response = await fetch('/api/templates/create-note', {
this.toast(error.detail || this.t('templates.create_failed'), { type: 'error' }); method: 'POST',
return; headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
templateName: this.selectedTemplate,
notePath: notePath
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || this.t('templates.create_failed'));
}
const data = await response.json();
this.lastUsedTemplate = this.selectedTemplate;
try { localStorage.setItem('lastUsedTemplate', this.selectedTemplate); } catch (_) {}
this.showTemplateModal = false;
this.selectedTemplate = '';
this.newTemplateNoteName = '';
await this.loadNote(data.path);
this.focusEditorForNewNote();
this.loadTagsDebounced();
} catch (err) {
this.toast(err.message || this.t('templates.create_failed'), { type: 'error' });
await this.loadNotes({ silent: true });
} }
const data = await response.json();
this.lastUsedTemplate = this.selectedTemplate;
try { localStorage.setItem('lastUsedTemplate', this.selectedTemplate); } catch (_) {}
// Close modal and reset state
this.showTemplateModal = false;
this.selectedTemplate = '';
this.newTemplateNoteName = '';
// Reload notes and open the new note
await this.loadNotes();
await this.loadNote(data.path);
this.focusEditorForNewNote();
} catch (error) { } catch (error) {
ErrorHandler.handle('create note from template', error); ErrorHandler.handle('create note from template', error);
} }
@ -2259,6 +2286,108 @@ function noteApp() {
this.folderTree = tree; this.folderTree = tree;
}, },
// =====================================================================
// OPTIMISTIC MUTATION HELPERS
// Mirror server-side index updates locally so file ops feel instant on
// big vaults. Mutations apply the change to this.notes/this.allFolders
// immediately, fire the request, and on error fall back to
// loadNotes({silent: true}) to resync from disk.
// =====================================================================
_isoNow() {
return new Date().toISOString();
},
_folderFromPath(path) {
const i = path.lastIndexOf('/');
return i === -1 ? '' : path.substring(0, i);
},
_filenameFromPath(path) {
return path.split('/').pop();
},
_inferTypeFromPath(path) {
const m = /\.([^./]+)$/.exec(path);
const ext = m ? m[1].toLowerCase() : '';
if (ext === 'md' || ext === '') return 'note';
if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'].includes(ext)) return 'image';
if (['mp3', 'wav', 'ogg', 'm4a', 'flac'].includes(ext)) return 'audio';
if (['mp4', 'webm', 'mov', 'avi'].includes(ext)) return 'video';
if (ext === 'pdf') return 'document';
return 'note';
},
// Refresh sidebar tree + wikilink lookup maps after any optimistic update.
_rebuildTreeAfterMutation() {
this.buildNoteLookupMaps();
this.buildFolderTree();
},
// Add a note/media file to the local list. No-op if already present.
_optimisticAddNote(path, { content = '', type = null, size = null } = {}) {
if (this.notes.some(n => n.path === path)) return;
const inferredType = type || this._inferTypeFromPath(path);
const filename = this._filenameFromPath(path);
const name = inferredType === 'note' ? filename.replace(/\.md$/i, '') : filename;
this.notes.push({
path,
name,
folder: this._folderFromPath(path),
type: inferredType,
size: size != null ? size : (content ? new Blob([content]).size : 0),
modified: this._isoNow(),
tags: (inferredType === 'note' && content) ? this.parseTagsFromContent(content) : [],
});
},
_optimisticRemoveNote(path) {
this.notes = this.notes.filter(n => n.path !== path);
},
// Used by single-note rename and move (path changes, identity preserved).
_optimisticRenameNote(oldPath, newPath) {
const note = this.notes.find(n => n.path === oldPath);
if (!note) return;
note.path = newPath;
note.folder = this._folderFromPath(newPath);
const filename = this._filenameFromPath(newPath);
note.name = note.type === 'note' ? filename.replace(/\.md$/i, '') : filename;
},
_optimisticAddFolder(folderPath) {
if (!folderPath) return;
if (!this.allFolders.includes(folderPath)) {
this.allFolders.push(folderPath);
}
},
// Cascade: remove the folder, its descendant folders, and every note inside.
_optimisticRemoveFolderTree(folderPath) {
const prefix = folderPath + '/';
this.allFolders = this.allFolders.filter(f => f !== folderPath && !f.startsWith(prefix));
this.notes = this.notes.filter(n => !n.path.startsWith(prefix));
},
// Cascade: rename the folder, its descendant folders, and rewrite paths
// of every note inside.
_optimisticRenameFolderTree(oldPath, newPath) {
if (oldPath === newPath) return;
const oldPrefix = oldPath + '/';
const newPrefix = newPath + '/';
this.allFolders = this.allFolders.map(f => {
if (f === oldPath) return newPath;
if (f.startsWith(oldPrefix)) return newPrefix + f.substring(oldPrefix.length);
return f;
});
this.notes.forEach(n => {
if (n.path.startsWith(oldPrefix)) {
n.path = newPrefix + n.path.substring(oldPrefix.length);
n.folder = this._folderFromPath(n.path);
}
});
},
// ===================================================================== // =====================================================================
// DATA-ATTRIBUTE BASED HANDLERS // DATA-ATTRIBUTE BASED HANDLERS
// These read path/name/type from data-* attributes, avoiding JS escaping issues // These read path/name/type from data-* attributes, avoiding JS escaping issues
@ -2774,23 +2903,17 @@ function noteApp() {
if (cursorPos < 0) cursorPos = textarea.selectionStart || 0; if (cursorPos < 0) cursorPos = textarea.selectionStart || 0;
} }
let uploaded = false;
for (const file of mediaFiles) { for (const file of mediaFiles) {
try { try {
const mediaPath = await this.uploadMedia(file, notePath); const mediaPath = await this.uploadMedia(file, notePath);
if (mediaPath) { if (mediaPath && this.currentNote) {
uploaded = true; await this.insertMediaMarkdown(mediaPath, file.name, cursorPos);
if (this.currentNote) {
await this.insertMediaMarkdown(mediaPath, file.name, cursorPos);
}
} }
} catch (error) { } catch (error) {
ErrorHandler.handle(`upload file ${file.name}`, error); ErrorHandler.handle(`upload file ${file.name}`, error);
} }
} }
if (uploaded && !this.currentNote) { // uploadMedia already injects the file into this.notes optimistically.
await this.loadNotes();
}
}, },
// Upload a media file (image, audio, video, PDF) // Upload a media file (image, audio, video, PDF)
@ -2807,49 +2930,38 @@ function noteApp() {
formData.append('note_path', notePath || ''); formData.append('note_path', notePath || '');
} }
try { const response = await fetch('/api/upload-media', {
const response = await fetch('/api/upload-media', { method: 'POST',
method: 'POST', body: formData
body: formData });
}); if (!response.ok) {
const error = await response.json();
if (!response.ok) { throw new Error(error.detail || 'Upload failed');
const error = await response.json();
throw new Error(error.detail || 'Upload failed');
}
const data = await response.json();
return data.path;
} catch (error) {
throw error;
} }
const data = await response.json();
// Drop the new file into the local note list so the wikilink
// resolver finds it without a full /api/notes refresh.
if (data.path) {
this._optimisticAddNote(data.path, { size: file.size });
this._rebuildTreeAfterMutation();
}
return data.path;
}, },
// Insert media markdown at cursor position using wiki-style syntax // Insert media markdown at cursor position using wiki-style syntax
// This ensures media links don't break when notes are moved // (media links don't break when notes are moved). The uploaded file is
// already in this.notes thanks to uploadMedia()'s optimistic add.
async insertMediaMarkdown(mediaPath, altText, cursorPos) { async insertMediaMarkdown(mediaPath, altText, cursorPos) {
// Extract just the filename from the path (e.g., "folder/_attachments/image.png" -> "image.png")
const filename = mediaPath.split('/').pop(); const filename = mediaPath.split('/').pop();
// Use wiki-style embed link: ![[filename.png]] or ![[filename.png|alt text]]
// The alt text is optional - only add if different from filename
const filenameWithoutExt = filename.replace(/\.[^/.]+$/, ''); const filenameWithoutExt = filename.replace(/\.[^/.]+$/, '');
const altWithoutExt = altText.replace(/\.[^/.]+$/, ''); const altWithoutExt = altText.replace(/\.[^/.]+$/, '');
// If alt text is meaningful (not just "pasted-image"), include it
const markdown = (altWithoutExt && altWithoutExt !== filenameWithoutExt && !altWithoutExt.startsWith('pasted-image')) const markdown = (altWithoutExt && altWithoutExt !== filenameWithoutExt && !altWithoutExt.startsWith('pasted-image'))
? `![[${filename}|${altWithoutExt}]]` ? `![[${filename}|${altWithoutExt}]]`
: `![[${filename}]]`; : `![[${filename}]]`;
// Reload notes FIRST to update image lookup maps before preview renders
await this.loadNotes();
const textBefore = this.noteContent.substring(0, cursorPos); const textBefore = this.noteContent.substring(0, cursorPos);
const textAfter = this.noteContent.substring(cursorPos); const textAfter = this.noteContent.substring(cursorPos);
this.noteContent = textBefore + markdown + '\n' + textAfter; this.noteContent = textBefore + markdown + '\n' + textAfter;
// Trigger autosave
this.autoSave(); this.autoSave();
}, },
@ -2989,23 +3101,18 @@ function noteApp() {
}); });
if (!ok) return; if (!ok) return;
this._optimisticRemoveNote(mediaPath);
this._rebuildTreeAfterMutation();
if (this.currentMedia === mediaPath) this.currentMedia = '';
try { try {
const response = await fetch(`/api/notes/${encodeURIComponent(mediaPath)}`, { const response = await fetch(`/api/notes/${encodeURIComponent(mediaPath)}`, {
method: 'DELETE' method: 'DELETE'
}); });
if (!response.ok) throw new Error('Failed to delete media file');
if (response.ok) {
await this.loadNotes(); // Refresh tree
// Clear viewer if deleting currently viewed media
if (this.currentMedia === mediaPath) {
this.currentMedia = '';
}
} else {
throw new Error('Failed to delete media file');
}
} catch (error) { } catch (error) {
ErrorHandler.handle('delete media', error); ErrorHandler.handle('delete media', error);
await this.loadNotes({ silent: true });
} }
}, },
@ -3047,10 +3154,14 @@ function noteApp() {
nextToNotes: true, nextToNotes: true,
contentFolder: targetFolder, contentFolder: targetFolder,
}); });
await this.loadNotes(); // Server returns the final upload path (may differ from
// 'drawing.png' if it added a timestamp suffix).
this._optimisticAddNote(path, { type: 'image', size: blob.size });
this._rebuildTreeAfterMutation();
this.viewMedia(path, 'drawing'); this.viewMedia(path, 'drawing');
} catch (error) { } catch (error) {
ErrorHandler.handle('create drawing', error); ErrorHandler.handle('create drawing', error);
await this.loadNotes({ silent: true });
} }
}, },
@ -3941,7 +4052,13 @@ function noteApp() {
} }
throw new Error(detail || res.statusText); throw new Error(detail || res.statusText);
} }
await this.loadNotes(); // Drawing file already in this.notes — only metadata changed
// (size/mtime). Bump locally instead of a full /api/notes scan.
const rec = this.notes.find(n => n.path === this.currentMedia);
if (rec) {
rec.size = blob.size;
rec.modified = this._isoNow();
}
this.lastSaved = true; this.lastSaved = true;
setTimeout(() => { setTimeout(() => {
this.lastSaved = false; this.lastSaved = false;
@ -4108,9 +4225,30 @@ function noteApp() {
if (newPath === draggedPath) return; if (newPath === draggedPath) return;
// Capture favorites info before async call
const oldPrefix = draggedPath + '/'; const oldPrefix = draggedPath + '/';
const newPrefix = newPath + '/'; const newPrefix = newPath + '/';
const wasExpanded = this.expandedFolders.has(draggedPath);
this._optimisticRenameFolderTree(draggedPath, newPath);
this._rebuildTreeAfterMutation();
const favoritesInFolder = this.favorites.filter(f => f.startsWith(oldPrefix));
if (favoritesInFolder.length > 0) {
const newFavorites = this.favorites.map(f =>
f.startsWith(oldPrefix) ? newPrefix + f.substring(oldPrefix.length) : f
);
this.favorites = newFavorites;
this.favoritesSet = new Set(newFavorites);
this.saveFavorites();
}
if (wasExpanded) {
this.expandedFolders.delete(draggedPath);
this.expandedFolders.add(newPath);
this.saveExpandedFolders();
}
if (this.currentNote && this.currentNote.startsWith(oldPrefix)) {
this.currentNote = newPrefix + this.currentNote.substring(oldPrefix.length);
}
try { try {
const response = await fetch('/api/folders/move', { const response = await fetch('/api/folders/move', {
@ -4118,37 +4256,15 @@ function noteApp() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oldPath: draggedPath, newPath }) body: JSON.stringify({ oldPath: draggedPath, newPath })
}); });
if (!response.ok) {
if (response.ok) {
// Update favorites for notes inside moved folder
const favoritesInFolder = this.favorites.filter(f => f.startsWith(oldPrefix));
if (favoritesInFolder.length > 0) {
const newFavorites = this.favorites.map(f =>
f.startsWith(oldPrefix) ? newPrefix + f.substring(oldPrefix.length) : f
);
this.favorites = newFavorites;
this.favoritesSet = new Set(newFavorites);
this.saveFavorites();
}
// Keep folder expanded if it was
const wasExpanded = this.expandedFolders.has(draggedPath);
await this.loadNotes();
await this.loadSharedNotePaths();
if (wasExpanded) {
this.expandedFolders.delete(draggedPath);
this.expandedFolders.add(newPath);
this.saveExpandedFolders();
}
} else {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
this.toast(errorData.detail || this.t('move.failed_folder'), { type: 'error' }); throw new Error(errorData.detail || this.t('move.failed_folder'));
} }
await this.loadSharedNotePaths();
} catch (error) { } catch (error) {
console.error('Failed to move folder:', error); console.error('Failed to move folder:', error);
this.toast(this.t('move.failed_folder'), { type: 'error' }); this.toast(error.message || this.t('move.failed_folder'), { type: 'error' });
await this.loadNotes({ silent: true });
} }
return; return;
} }
@ -4162,47 +4278,38 @@ function noteApp() {
if (newPath === draggedPath) return; if (newPath === draggedPath) return;
// Check if note is favorited (only for notes)
const wasFavorited = isNote && this.favoritesSet.has(draggedPath); const wasFavorited = isNote && this.favoritesSet.has(draggedPath);
const wasCurrentNote = this.currentNote === draggedPath;
const wasCurrentMedia = this.currentMedia === draggedPath;
this._optimisticRenameNote(draggedPath, newPath);
this._rebuildTreeAfterMutation();
if (wasFavorited) {
this.favorites = this.favorites.map(f => f === draggedPath ? newPath : f);
this.favoritesSet = new Set(this.favorites);
this.saveFavorites();
}
if (wasCurrentNote) this.currentNote = newPath;
if (wasCurrentMedia) this.currentMedia = newPath;
try { try {
// Use different endpoint for media vs notes
const endpoint = isMedia ? '/api/media/move' : '/api/notes/move'; const endpoint = isMedia ? '/api/media/move' : '/api/notes/move';
const response = await fetch(endpoint, { const response = await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oldPath: draggedPath, newPath }) body: JSON.stringify({ oldPath: draggedPath, newPath })
}); });
if (!response.ok) {
if (response.ok) {
// Update favorites if the moved note was favorited
if (wasFavorited) {
const newFavorites = this.favorites.map(f => f === draggedPath ? newPath : f);
this.favorites = newFavorites;
this.favoritesSet = new Set(newFavorites);
this.saveFavorites();
}
// Keep current item open if it was the moved one
const wasCurrentNote = this.currentNote === draggedPath;
const wasCurrentMedia = this.currentMedia === draggedPath;
await this.loadNotes();
if (isNote) {
await this.loadSharedNotePaths();
}
if (wasCurrentNote) this.currentNote = newPath;
if (wasCurrentMedia) this.currentMedia = newPath;
} else {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note'; const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note';
this.toast(errorData.detail || this.t(errorKey), { type: 'error' }); throw new Error(errorData.detail || this.t(errorKey));
} }
if (isNote) await this.loadSharedNotePaths();
} catch (error) { } catch (error) {
console.error(`Failed to move ${isMedia ? 'media' : 'note'}:`, error); console.error(`Failed to move ${isMedia ? 'media' : 'note'}:`, error);
const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note'; this.toast(error.message || this.t(isMedia ? 'move.failed_media' : 'move.failed_note'), { type: 'error' });
this.toast(this.t(errorKey), { type: 'error' }); await this.loadNotes({ silent: true });
} }
}, },
@ -4812,50 +4919,48 @@ function noteApp() {
}, },
async _finalizeCreateNote(notePath) { async _finalizeCreateNote(notePath) {
// Optimistic add — sidebar reflects the new note instantly. Server
// confirms; on failure we resync silently.
this._optimisticAddNote(notePath, { content: '' });
const folderPart = notePath.includes('/') ? notePath.substring(0, notePath.lastIndexOf('/')) : '';
if (folderPart) this.expandedFolders.add(folderPart);
this._rebuildTreeAfterMutation();
try { try {
const response = await fetch(`/api/notes/${notePath}`, { const response = await fetch(`/api/notes/${notePath}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: '' }) body: JSON.stringify({ content: '' })
}); });
if (!response.ok) throw new Error('Server returned error');
if (response.ok) { await this.loadNote(notePath);
const folderPart = notePath.includes('/') ? notePath.substring(0, notePath.lastIndexOf('/')) : ''; this.focusEditorForNewNote();
if (folderPart) this.expandedFolders.add(folderPart); return true;
await this.loadNotes();
await this.loadNote(notePath);
this.focusEditorForNewNote();
return true;
}
ErrorHandler.handle('create note', new Error('Server returned error'));
return false;
} catch (error) { } catch (error) {
ErrorHandler.handle('create note', error); ErrorHandler.handle('create note', error);
await this.loadNotes({ silent: true });
return false; return false;
} }
}, },
async _finalizeCreateFolder(folderPath, targetFolder) { async _finalizeCreateFolder(folderPath, targetFolder) {
this._optimisticAddFolder(folderPath);
if (targetFolder) this.expandedFolders.add(targetFolder);
this.expandedFolders.add(folderPath);
this._rebuildTreeAfterMutation();
try { try {
const response = await fetch('/api/folders', { const response = await fetch('/api/folders', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: folderPath }) body: JSON.stringify({ path: folderPath })
}); });
if (!response.ok) throw new Error('Server returned error');
if (response.ok) { this.goToHomepageFolder(folderPath);
if (targetFolder) { return true;
this.expandedFolders.add(targetFolder);
}
this.expandedFolders.add(folderPath);
await this.loadNotes();
this.goToHomepageFolder(folderPath);
return true;
}
ErrorHandler.handle('create folder', new Error('Server returned error'));
return false;
} catch (error) { } catch (error) {
ErrorHandler.handle('create folder', error); ErrorHandler.handle('create folder', error);
await this.loadNotes({ silent: true });
return false; return false;
} }
}, },
@ -4952,90 +5057,80 @@ function noteApp() {
}, },
async _finalizeRenameFolder(folderPath, newPath) { async _finalizeRenameFolder(folderPath, newPath) {
const folderPrefix = folderPath + '/';
const newFolderPrefix = newPath + '/';
// Optimistic cascade: rewrite folder + every nested folder + every
// note path. Sidebar reflects the rename instantly.
this._optimisticRenameFolderTree(folderPath, newPath);
this._rebuildTreeAfterMutation();
if (this.expandedFolders.has(folderPath)) {
this.expandedFolders.delete(folderPath);
this.expandedFolders.add(newPath);
}
const newFavorites = this.favorites.map(f =>
f.startsWith(folderPrefix) ? newFolderPrefix + f.substring(folderPrefix.length) : f
);
if (JSON.stringify(newFavorites) !== JSON.stringify(this.favorites)) {
this.favorites = newFavorites;
this.favoritesSet = new Set(newFavorites);
this.saveFavorites();
}
if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
this.currentNote = newFolderPrefix + this.currentNote.substring(folderPrefix.length);
}
try { try {
const response = await fetch('/api/folders/rename', { const response = await fetch('/api/folders/rename', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ oldPath: folderPath, newPath: newPath })
oldPath: folderPath,
newPath: newPath
})
}); });
if (!response.ok) throw new Error('Server returned error');
if (response.ok) { return true;
if (this.expandedFolders.has(folderPath)) {
this.expandedFolders.delete(folderPath);
this.expandedFolders.add(newPath);
}
const folderPrefix = folderPath + '/';
const newFolderPrefix = newPath + '/';
const newFavorites = this.favorites.map(f => {
if (f.startsWith(folderPrefix)) {
return f.replace(folderPrefix, newFolderPrefix);
}
return f;
});
if (JSON.stringify(newFavorites) !== JSON.stringify(this.favorites)) {
this.favorites = newFavorites;
this.favoritesSet = new Set(newFavorites);
this.saveFavorites();
}
if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
this.currentNote = this.currentNote.replace(folderPrefix, newFolderPrefix);
}
await this.loadNotes();
return true;
}
ErrorHandler.handle('rename folder', new Error('Server returned error'));
return false;
} catch (error) { } catch (error) {
ErrorHandler.handle('rename folder', error); ErrorHandler.handle('rename folder', error);
await this.loadNotes({ silent: true });
return false; return false;
} }
}, },
// Delete folder // Delete folder (cascade: every nested folder + note)
async deleteFolder(folderPath, folderName) { async deleteFolder(folderPath, folderName) {
const ok = await this.confirmModalAsk({ const ok = await this.confirmModalAsk({
message: this.t('folders.confirm_delete', { name: folderName }), message: this.t('folders.confirm_delete', { name: folderName }),
}); });
if (!ok) return; if (!ok) return;
const folderPrefix = folderPath + '/';
this._optimisticRemoveFolderTree(folderPath);
this._rebuildTreeAfterMutation();
this.expandedFolders.delete(folderPath);
const newFavorites = this.favorites.filter(f => !f.startsWith(folderPrefix));
if (newFavorites.length !== this.favorites.length) {
this.favorites = newFavorites;
this.favoritesSet = new Set(newFavorites);
this.saveFavorites();
}
if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
this.currentNote = '';
this.noteContent = '';
document.title = this.appName;
}
try { try {
const response = await fetch(`/api/folders/${encodeURIComponent(folderPath)}`, { const response = await fetch(`/api/folders/${encodeURIComponent(folderPath)}`, {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}); });
if (!response.ok) throw new Error('Server returned error');
if (response.ok) { this.loadTagsDebounced();
// Remove from expanded folders
this.expandedFolders.delete(folderPath);
// Remove any favorites that were in the deleted folder
const folderPrefix = folderPath + '/';
const newFavorites = this.favorites.filter(f => !f.startsWith(folderPrefix));
if (newFavorites.length !== this.favorites.length) {
this.favorites = newFavorites;
this.favoritesSet = new Set(newFavorites);
this.saveFavorites();
}
// Clear current note if it was in the deleted folder
if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
this.currentNote = '';
this.noteContent = '';
document.title = this.appName;
}
await this.loadNotes();
} else {
ErrorHandler.handle('delete folder', new Error('Server returned error'));
}
} catch (error) { } catch (error) {
ErrorHandler.handle('delete folder', error); ErrorHandler.handle('delete folder', error);
await this.loadNotes({ silent: true });
} }
}, },
@ -5479,33 +5574,29 @@ function noteApp() {
return; return;
} }
// Create new note with same content // Optimistic rename: rewrite local path now + favorites + current
// note pointer + URL. POST new content, then DELETE old.
this._optimisticRenameNote(oldPath, newPath);
this._rebuildTreeAfterMutation();
if (this.favoritesSet.has(oldPath)) {
this.favorites = this.favorites.map(f => f === oldPath ? newPath : f);
this.favoritesSet = new Set(this.favorites);
this.saveFavorites();
}
this.currentNote = newPath;
try { try {
const response = await fetch(`/api/notes/${newPath}`, { const response = await fetch(`/api/notes/${newPath}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: this.noteContent }) body: JSON.stringify({ content: this.noteContent })
}); });
if (!response.ok) throw new Error('Server returned error');
if (response.ok) { await fetch(`/api/notes/${oldPath}`, { method: 'DELETE' });
// Delete old note
await fetch(`/api/notes/${oldPath}`, { method: 'DELETE' });
// Update favorites if the renamed note was favorited
if (this.favoritesSet.has(oldPath)) {
const newFavorites = this.favorites.map(f => f === oldPath ? newPath : f);
this.favorites = newFavorites;
this.favoritesSet = new Set(newFavorites);
this.saveFavorites();
}
this.currentNote = newPath;
await this.loadNotes();
} else {
ErrorHandler.handle('rename note', new Error('Server returned error'));
}
} catch (error) { } catch (error) {
ErrorHandler.handle('rename note', error); ErrorHandler.handle('rename note', error);
await this.loadNotes({ silent: true });
} }
}, },
@ -5524,39 +5615,36 @@ function noteApp() {
}); });
if (!ok) return; if (!ok) return;
// Optimistic: remove locally + clear current note + drop favorite
// before the network round-trip. Sidebar refreshes in <1ms on any
// vault size. On error we resync silently from disk.
this._optimisticRemoveNote(notePath);
this._rebuildTreeAfterMutation();
if (this.favoritesSet.has(notePath)) {
this.favorites = this.favorites.filter(f => f !== notePath);
this.favoritesSet = new Set(this.favorites);
this.saveFavorites();
}
if (this.currentNote === notePath) {
this.currentNote = '';
this.noteContent = '';
this.currentNoteName = '';
this._lastRenderedContent = '';
this._lastRenderedNote = '';
this._cachedRenderedHTML = '';
document.title = this.appName;
window.history.replaceState({}, '', '/');
}
try { try {
const response = await fetch(`/api/notes/${notePath}`, { const response = await fetch(`/api/notes/${notePath}`, { method: 'DELETE' });
method: 'DELETE' if (!response.ok) throw new Error('Server returned error');
}); this.loadTagsDebounced();
if (response.ok) {
// Remove from favorites if it was favorited
if (this.favoritesSet.has(notePath)) {
const newFavorites = this.favorites.filter(f => f !== notePath);
this.favorites = newFavorites;
this.favoritesSet = new Set(newFavorites);
this.saveFavorites();
}
// If the deleted note is currently open, clear it
if (this.currentNote === notePath) {
this.currentNote = '';
this.noteContent = '';
this.currentNoteName = '';
this._lastRenderedContent = ''; // Clear render cache
this._lastRenderedNote = '';
this._cachedRenderedHTML = '';
document.title = this.appName;
// Redirect to root
window.history.replaceState({}, '', '/');
}
await this.loadNotes();
} else {
ErrorHandler.handle('delete note', new Error('Server returned error'));
}
} catch (error) { } catch (error) {
ErrorHandler.handle('delete note', error); ErrorHandler.handle('delete note', error);
await this.loadNotes({ silent: true });
} }
}, },

View File

@ -1398,6 +1398,29 @@
</head> </head>
<body x-data="noteApp()" x-init="init()" style="background-color: var(--bg-primary);" :class="{'zen-mode-active': zenMode}"> <body x-data="noteApp()" x-init="init()" style="background-color: var(--bg-primary);" :class="{'zen-mode-active': zenMode}">
<!-- Vault loading overlay. Centered floating card; the outer div is
pointer-events:none so settings/theme/search/icon-rail stay clickable.
Shown only when /api/notes is still in flight 800ms after boot AND the
tree is currently empty — so small vaults / warm caches never see it. -->
<div x-show="notesLoadingShowOverlay"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 flex items-center justify-center"
style="z-index: 60; pointer-events: none;">
<div class="px-6 py-4 rounded-lg shadow-2xl flex items-center gap-3"
style="background-color: var(--bg-secondary); border: 1px solid var(--border-primary); color: var(--text-primary); pointer-events: auto;">
<svg class="animate-spin h-5 w-5 flex-shrink-0" style="color: var(--accent-primary);" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-sm" x-text="t('sidebar.loading_notes')"></span>
</div>
</div>
<!-- Zen Mode Exit Button --> <!-- Zen Mode Exit Button -->
<button <button
x-show="zenMode" x-show="zenMode"
@ -1800,8 +1823,53 @@
<!-- Root notes and images (no folder) --> <!-- Root notes and images (no folder) -->
<div x-html="renderRootItems()"></div> <div x-html="renderRootItems()"></div>
<!-- Empty state --> <!-- Loading state (initial /api/notes still in flight).
<template x-if="notes.length === 0 && allFolders.length === 0"> Static shimmer skeleton — pure CSS, no state. Folder
rows are full-width with a square icon; note rows
are indented with a smaller icon. Staggered animation
delays make the rows pulse out of sync so the column
feels alive instead of strobing. The 800ms overlay
still appears for big-vault cold loads on top of
this — they layer naturally. -->
<template x-if="notesLoading && notes.length === 0 && allFolders.length === 0">
<div class="px-2 py-2 space-y-1" aria-busy="true" :aria-label="t('sidebar.loading_notes')">
<div class="flex items-center gap-2 py-1.5 px-2 animate-pulse">
<div class="h-4 w-4 rounded shrink-0" style="background-color: var(--bg-hover);"></div>
<div class="h-3 rounded" style="width: 62%; background-color: var(--bg-hover);"></div>
</div>
<div class="flex items-center gap-2 py-1.5 pl-7 pr-2 animate-pulse" style="animation-delay: .12s;">
<div class="h-3 w-3 rounded shrink-0" style="background-color: var(--bg-hover);"></div>
<div class="h-3 rounded" style="width: 48%; background-color: var(--bg-hover);"></div>
</div>
<div class="flex items-center gap-2 py-1.5 pl-7 pr-2 animate-pulse" style="animation-delay: .22s;">
<div class="h-3 w-3 rounded shrink-0" style="background-color: var(--bg-hover);"></div>
<div class="h-3 rounded" style="width: 70%; background-color: var(--bg-hover);"></div>
</div>
<div class="flex items-center gap-2 py-1.5 px-2 animate-pulse" style="animation-delay: .32s;">
<div class="h-4 w-4 rounded shrink-0" style="background-color: var(--bg-hover);"></div>
<div class="h-3 rounded" style="width: 55%; background-color: var(--bg-hover);"></div>
</div>
<div class="flex items-center gap-2 py-1.5 pl-7 pr-2 animate-pulse" style="animation-delay: .42s;">
<div class="h-3 w-3 rounded shrink-0" style="background-color: var(--bg-hover);"></div>
<div class="h-3 rounded" style="width: 38%; background-color: var(--bg-hover);"></div>
</div>
<div class="flex items-center gap-2 py-1.5 pl-7 pr-2 animate-pulse" style="animation-delay: .52s;">
<div class="h-3 w-3 rounded shrink-0" style="background-color: var(--bg-hover);"></div>
<div class="h-3 rounded" style="width: 80%; background-color: var(--bg-hover);"></div>
</div>
<div class="flex items-center gap-2 py-1.5 px-2 animate-pulse" style="animation-delay: .62s;">
<div class="h-4 w-4 rounded shrink-0" style="background-color: var(--bg-hover);"></div>
<div class="h-3 rounded" style="width: 42%; background-color: var(--bg-hover);"></div>
</div>
<div class="flex items-center gap-2 py-1.5 pl-7 pr-2 animate-pulse" style="animation-delay: .72s;">
<div class="h-3 w-3 rounded shrink-0" style="background-color: var(--bg-hover);"></div>
<div class="h-3 rounded" style="width: 50%; background-color: var(--bg-hover);"></div>
</div>
</div>
</template>
<!-- Empty state (load finished, vault genuinely empty) -->
<template x-if="!notesLoading && notes.length === 0 && allFolders.length === 0">
<div class="px-3 py-4 text-sm text-center" style="color: var(--text-tertiary);"> <div class="px-3 py-4 text-sm text-center" style="color: var(--text-tertiary);">
<span x-text="t('sidebar.no_notes_yet')"></span><br> <span x-text="t('sidebar.no_notes_yet')"></span><br>
<span x-text="t('sidebar.create_first')"></span> <span x-text="t('sidebar.create_first')"></span>
@ -2432,8 +2500,8 @@
</button> </button>
</div> </div>
<!-- Welcome Hero - Show when app is empty --> <!-- Welcome Hero - Show when app is empty (but not while still loading) -->
<template x-if="isAppEmpty"> <template x-if="!notesLoading && isAppEmpty">
<div class="flex flex-col items-center justify-center h-full py-16 text-center"> <div class="flex flex-col items-center justify-center h-full py-16 text-center">
<svg class="mx-auto h-24 w-24 mb-4" style="color: var(--text-tertiary); opacity: 0.5;" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="mx-auto h-24 w-24 mb-4" style="color: var(--text-tertiary); opacity: 0.5;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>

View File

@ -49,6 +49,7 @@
"no_notes": "Du hast noch keine Notizen. Erstelle deine erste!", "no_notes": "Du hast noch keine Notizen. Erstelle deine erste!",
"no_notes_yet": "Noch keine Notizen oder Ordner.", "no_notes_yet": "Noch keine Notizen oder Ordner.",
"create_first": "Erstelle deine erste Notiz oder Ordner!", "create_first": "Erstelle deine erste Notiz oder Ordner!",
"loading_notes": "Notizen werden geladen…",
"drop_to_root": "Hier ablegen für Stammverzeichnis", "drop_to_root": "Hier ablegen für Stammverzeichnis",
"no_favorites": "Noch keine Favoriten", "no_favorites": "Noch keine Favoriten",
"no_results": "Keine Ergebnisse gefunden", "no_results": "Keine Ergebnisse gefunden",

View File

@ -49,6 +49,7 @@
"no_notes": "No notes yet. Create your first note!", "no_notes": "No notes yet. Create your first note!",
"no_notes_yet": "No notes or folders yet.", "no_notes_yet": "No notes or folders yet.",
"create_first": "Create your first note or folder!", "create_first": "Create your first note or folder!",
"loading_notes": "Loading your notes…",
"no_favorites": "No favourites yet", "no_favorites": "No favourites yet",
"no_results": "No results found", "no_results": "No results found",
"expand_all": "Expand all folders", "expand_all": "Expand all folders",

View File

@ -49,6 +49,7 @@
"no_notes": "No notes yet. Create your first note!", "no_notes": "No notes yet. Create your first note!",
"no_notes_yet": "No notes or folders yet.", "no_notes_yet": "No notes or folders yet.",
"create_first": "Create your first note or folder!", "create_first": "Create your first note or folder!",
"loading_notes": "Loading your notes…",
"drop_to_root": "Drop here for root", "drop_to_root": "Drop here for root",
"no_favorites": "No favorites yet", "no_favorites": "No favorites yet",
"no_results": "No results found", "no_results": "No results found",

View File

@ -49,6 +49,7 @@
"no_notes": "Aún no hay notas. ¡Crea tu primera nota!", "no_notes": "Aún no hay notas. ¡Crea tu primera nota!",
"no_notes_yet": "Aún no hay notas ni carpetas.", "no_notes_yet": "Aún no hay notas ni carpetas.",
"create_first": "¡Crea tu primera nota o carpeta!", "create_first": "¡Crea tu primera nota o carpeta!",
"loading_notes": "Cargando tus notas…",
"drop_to_root": "Soltar aquí para raíz", "drop_to_root": "Soltar aquí para raíz",
"no_favorites": "Aún no hay favoritos", "no_favorites": "Aún no hay favoritos",
"no_results": "No se encontraron resultados", "no_results": "No se encontraron resultados",

View File

@ -49,6 +49,7 @@
"no_notes": "Pas encore de notes. Créez votre première note !", "no_notes": "Pas encore de notes. Créez votre première note !",
"no_notes_yet": "Pas encore de notes ni de dossiers.", "no_notes_yet": "Pas encore de notes ni de dossiers.",
"create_first": "Créez votre première note ou dossier !", "create_first": "Créez votre première note ou dossier !",
"loading_notes": "Chargement de vos notes…",
"drop_to_root": "Déposer ici pour la racine", "drop_to_root": "Déposer ici pour la racine",
"no_favorites": "Pas encore de favoris", "no_favorites": "Pas encore de favoris",
"no_results": "Aucun résultat trouvé", "no_results": "Aucun résultat trouvé",

View File

@ -49,6 +49,7 @@
"no_notes": "Még nincsenek jegyzetek. Hozd létre az első jegyzeted!", "no_notes": "Még nincsenek jegyzetek. Hozd létre az első jegyzeted!",
"no_notes_yet": "Még nincsenek jegyzetek vagy mappák.", "no_notes_yet": "Még nincsenek jegyzetek vagy mappák.",
"create_first": "Hozd létre az első jegyzeted vagy mappád!", "create_first": "Hozd létre az első jegyzeted vagy mappád!",
"loading_notes": "Jegyzetek betöltése…",
"drop_to_root": "Húzd ide a gyökérhez", "drop_to_root": "Húzd ide a gyökérhez",
"no_favorites": "Még nincsenek kedvencek", "no_favorites": "Még nincsenek kedvencek",
"no_results": "Nincs találat", "no_results": "Nincs találat",

View File

@ -49,6 +49,7 @@
"no_notes": "Nessuna nota. Crea la tua prima nota!", "no_notes": "Nessuna nota. Crea la tua prima nota!",
"no_notes_yet": "Nessuna nota o cartella.", "no_notes_yet": "Nessuna nota o cartella.",
"create_first": "Crea la tua prima nota o cartella!", "create_first": "Crea la tua prima nota o cartella!",
"loading_notes": "Caricamento delle tue note…",
"no_favorites": "Nessun preferito", "no_favorites": "Nessun preferito",
"no_results": "Nessun risultato trovato", "no_results": "Nessun risultato trovato",
"expand_all": "Espandi tutte le cartelle", "expand_all": "Espandi tutte le cartelle",

View File

@ -49,6 +49,7 @@
"no_notes": "ノートがありません。最初のノートを作成しましょう!", "no_notes": "ノートがありません。最初のノートを作成しましょう!",
"no_notes_yet": "ノートやフォルダがありません。", "no_notes_yet": "ノートやフォルダがありません。",
"create_first": "最初のノートまたはフォルダを作成しましょう!", "create_first": "最初のノートまたはフォルダを作成しましょう!",
"loading_notes": "ノートを読み込み中…",
"no_favorites": "お気に入りがありません", "no_favorites": "お気に入りがありません",
"no_results": "結果が見つかりません", "no_results": "結果が見つかりません",
"expand_all": "すべて展開", "expand_all": "すべて展開",

View File

@ -49,6 +49,7 @@
"no_notes": "Заметок пока нет. Создайте первую!", "no_notes": "Заметок пока нет. Создайте первую!",
"no_notes_yet": "Нет заметок или папок.", "no_notes_yet": "Нет заметок или папок.",
"create_first": "Создайте первую заметку или папку!", "create_first": "Создайте первую заметку или папку!",
"loading_notes": "Загрузка ваших заметок…",
"no_favorites": "Нет избранного", "no_favorites": "Нет избранного",
"no_results": "Ничего не найдено", "no_results": "Ничего не найдено",
"expand_all": "Развернуть все папки", "expand_all": "Развернуть все папки",

View File

@ -49,6 +49,7 @@
"no_notes": "Ni še zapiskov. Ustvarite svoj prvi zapis!", "no_notes": "Ni še zapiskov. Ustvarite svoj prvi zapis!",
"no_notes_yet": "Ni še zapiskov ali map.", "no_notes_yet": "Ni še zapiskov ali map.",
"create_first": "Ustvarite svoj prvi zapis ali mapo!", "create_first": "Ustvarite svoj prvi zapis ali mapo!",
"loading_notes": "Nalaganje vaših zapiskov…",
"no_favorites": "Ni priljubljenih", "no_favorites": "Ni priljubljenih",
"no_results": "Ni najdenih rezultatov", "no_results": "Ni najdenih rezultatov",
"expand_all": "Razširi vse mape", "expand_all": "Razširi vse mape",

View File

@ -49,6 +49,7 @@
"no_notes": "还没有笔记。创建您的第一篇笔记!", "no_notes": "还没有笔记。创建您的第一篇笔记!",
"no_notes_yet": "还没有笔记或文件夹。", "no_notes_yet": "还没有笔记或文件夹。",
"create_first": "创建您的第一篇笔记或文件夹!", "create_first": "创建您的第一篇笔记或文件夹!",
"loading_notes": "正在加载您的笔记…",
"no_favorites": "还没有收藏夹", "no_favorites": "还没有收藏夹",
"no_results": "未找到结果", "no_results": "未找到结果",
"expand_all": "展开所有文件夹", "expand_all": "展开所有文件夹",

View File

@ -1,92 +1,58 @@
""" """
Note Statistics Plugin for NoteDiscovery Note Statistics Plugin for NoteDiscovery
Calculates and logs statistics about your notes
Shows: Computes per-note metrics (words, sentences, reading time, links, tasks, )
- Word count returned via /api/plugins/note_stats/calculate and consumed by the frontend
- Character count stats panel. On save we also emit a one-line INFO summary for quick
- Reading time estimate visibility in the server logs.
- Number of links
- Number of code blocks
- Line count
""" """
import logging
import re import re
logger = logging.getLogger("uvicorn.error")
WORDS_PER_MINUTE = 200
class Plugin: class Plugin:
def __init__(self): def __init__(self):
self.name = "Note Statistics" self.name = "Note Statistics"
self.version = "1.0.0" self.version = "1.0.0"
self.enabled = True self.enabled = True
self.stats_history = {}
def calculate_stats(self, content: str) -> dict: def calculate_stats(self, content: str) -> dict:
"""Calculate comprehensive note statistics""" """Compute the full metric set returned to the frontend / API."""
# Word count (split by whitespace and filter empty) words = len(re.findall(r'\S+', content))
words = len([w for w in re.findall(r'\S+', content) if w])
# Character count (excluding whitespace)
chars = len(re.sub(r'\s', '', content)) chars = len(re.sub(r'\s', '', content))
# Total character count (including whitespace)
total_chars = len(content) total_chars = len(content)
reading_time = max(1, round(words / WORDS_PER_MINUTE))
# Reading time (average 200 words per minute)
reading_time = max(1, round(words / 200))
# Line count
lines = len(content.split('\n')) lines = len(content.split('\n'))
# Paragraph count (blocks separated by blank lines)
paragraphs = len([p for p in content.split('\n\n') if p.strip()]) paragraphs = len([p for p in content.split('\n\n') if p.strip()])
# Sentence count: punctuation [.!?]+ followed by space or end-of-string
sentences = len(re.findall(r'[.!?]+(?:\s|$)', content)) sentences = len(re.findall(r'[.!?]+(?:\s|$)', content))
# List items: lines starting with -, *, + or a number (e.g. 1., 10.), excluding tasks [-] # Bullet/numbered list items, excluding task checkboxes like "- [ ]".
list_items = len( list_items = len(re.findall(r'^\s*(?:[-*+]|\d+\.)\s+(?!\[)', content, re.MULTILINE))
re.findall(r'^\s*(?:[-*+]|\d+\.)\s+(?!\[)', content, re.MULTILINE) # Markdown table separator rows: | --- | :--: |
) tables = len(re.findall(r'^\s*\|(?:\s*:?-+:?\s*\|){1,}\s*$', content, re.MULTILINE))
# Tables: count markdown table separator rows (| --- | --- |)
tables = len(
re.findall(r'^\s*\|(?:\s*:?-+:?\s*\|){1,}\s*$', content, re.MULTILINE)
)
# Markdown link count (standard [text](url) format)
markdown_links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+)\)', content)) markdown_links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+)\)', content))
# Internal link count (standard markdown links to .md files)
markdown_internal_links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+\.md)\)', content)) markdown_internal_links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+\.md)\)', content))
# Wikilink count ([[note]] or [[note|display text]] format - Obsidian style)
wikilinks = len(re.findall(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]', content)) wikilinks = len(re.findall(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]', content))
# Total links and internal links
links = markdown_links + wikilinks links = markdown_links + wikilinks
internal_links = markdown_internal_links + wikilinks # All wikilinks are internal internal_links = markdown_internal_links + wikilinks # wikilinks are always internal
# Code block count
code_blocks = len(re.findall(r'```[\s\S]*?```', content)) code_blocks = len(re.findall(r'```[\s\S]*?```', content))
# Inline code count
inline_code = len(re.findall(r'`[^`]+`', content)) inline_code = len(re.findall(r'`[^`]+`', content))
# Heading count by level
h1_count = len(re.findall(r'^# ', content, re.MULTILINE)) h1_count = len(re.findall(r'^# ', content, re.MULTILINE))
h2_count = len(re.findall(r'^## ', content, re.MULTILINE)) h2_count = len(re.findall(r'^## ', content, re.MULTILINE))
h3_count = len(re.findall(r'^### ', content, re.MULTILINE)) h3_count = len(re.findall(r'^### ', content, re.MULTILINE))
# Task count (checkboxes)
total_tasks = len(re.findall(r'- \[[ x]\]', content)) total_tasks = len(re.findall(r'- \[[ x]\]', content))
completed_tasks = len(re.findall(r'- \[x\]', content, re.IGNORECASE)) completed_tasks = len(re.findall(r'- \[x\]', content, re.IGNORECASE))
pending_tasks = total_tasks - completed_tasks
# Image count
images = len(re.findall(r'!\[([^\]]*)\]\(([^\)]+)\)', content)) images = len(re.findall(r'!\[([^\]]*)\]\(([^\)]+)\)', content))
# Blockquote count
blockquotes = len(re.findall(r'^> ', content, re.MULTILINE)) blockquotes = len(re.findall(r'^> ', content, re.MULTILINE))
return { return {
@ -109,87 +75,34 @@ class Plugin:
'h1': h1_count, 'h1': h1_count,
'h2': h2_count, 'h2': h2_count,
'h3': h3_count, 'h3': h3_count,
'total': h1_count + h2_count + h3_count 'total': h1_count + h2_count + h3_count,
}, },
'tasks': { 'tasks': {
'total': total_tasks, 'total': total_tasks,
'completed': completed_tasks, 'completed': completed_tasks,
'pending': pending_tasks, 'pending': total_tasks - completed_tasks,
'completion_rate': round(completed_tasks / total_tasks * 100) if total_tasks > 0 else 0 'completion_rate': round(completed_tasks / total_tasks * 100) if total_tasks else 0,
}, },
'images': images, 'images': images,
'blockquotes': blockquotes 'blockquotes': blockquotes,
} }
def format_stats(self, stats: dict) -> str:
"""Format statistics as a readable string"""
lines = [
f"📊 Statistics:",
f" Words: {stats['words']:,}",
f" Reading time: ~{stats['reading_time_minutes']} min",
f" Lines: {stats['lines']:,}",
]
if stats['links'] > 0:
lines.append(f" Links: {stats['links']} ({stats['internal_links']} internal, {stats['external_links']} external)")
if stats['code_blocks'] > 0:
lines.append(f" Code blocks: {stats['code_blocks']}")
if stats['tasks']['total'] > 0:
lines.append(f" Tasks: {stats['tasks']['completed']}/{stats['tasks']['total']} completed ({stats['tasks']['completion_rate']}%)")
if stats['headings']['total'] > 0:
lines.append(f" Headings: {stats['headings']['total']} (H1: {stats['headings']['h1']}, H2: {stats['headings']['h2']}, H3: {stats['headings']['h3']})")
return '\n'.join(lines)
def on_note_save(self, note_path: str, content: str) -> str | None: def on_note_save(self, note_path: str, content: str) -> str | None:
"""Calculate and log statistics when note is saved""" """Emit a one-line summary on save. Doesn't modify content."""
stats = self.calculate_stats(content) s = self.calculate_stats(content)
parts = [
# Store stats history f"{s['words']:,} words",
self.stats_history[note_path] = stats f"{s['sentences']:,} sentences",
f"~{s['reading_time_minutes']}m read",
# Log key statistics f"{s['lines']:,} lines",
print(f"📊 {note_path}:") ]
print( if s['list_items']:
f" {stats['words']:,} words | " parts.append(f"{s['list_items']:,} lists")
f"{stats['sentences']:,} sentences | " if s['tables']:
f"{stats['reading_time_minutes']}m read | " parts.append(f"{s['tables']:,} tables")
f"{stats['lines']:,} lines | " if s['links']:
f"{stats['list_items']:,} lists | " parts.append(f"{s['links']} links ({s['internal_links']} internal)")
f"{stats['tables']:,} tables" if s['tasks']['total']:
) parts.append(f"{s['tasks']['completed']}/{s['tasks']['total']} tasks")
logger.info("note_stats %s | %s", note_path, " | ".join(parts))
if stats['links'] > 0: return None
print(f" {stats['links']} links ({stats['internal_links']} internal)")
if stats['tasks']['total'] > 0:
print(f" {stats['tasks']['completed']}/{stats['tasks']['total']} tasks completed")
return None # Don't modify content, just observe
def get_stats(self, note_path: str) -> dict:
"""Get cached statistics for a note"""
return self.stats_history.get(note_path, {})
def get_total_stats(self) -> dict:
"""Get aggregated statistics across all notes"""
if not self.stats_history:
return {}
total_words = sum(s['words'] for s in self.stats_history.values())
total_notes = len(self.stats_history)
total_links = sum(s['links'] for s in self.stats_history.values())
total_tasks = sum(s['tasks']['total'] for s in self.stats_history.values())
return {
'total_notes': total_notes,
'total_words': total_words,
'average_words_per_note': round(total_words / total_notes) if total_notes > 0 else 0,
'total_links': total_links,
'total_tasks': total_tasks,
'total_reading_time': sum(s['reading_time_minutes'] for s in self.stats_history.values())
}

45
run.py
View File

@ -16,58 +16,33 @@ except ImportError:
colorama = None colorama = None
def get_port(): def get_port():
"""Get port from: 1) ENV variable, 2) config.yaml, 3) default 8000""" """Get port from: 1) PORT env var, 2) config.yaml, 3) default 8000."""
# Priority 1: Environment variable
if os.getenv("PORT"): if os.getenv("PORT"):
return os.getenv("PORT") return os.getenv("PORT")
# Priority 2: config.yaml
config_path = Path("config.yaml") config_path = Path("config.yaml")
if config_path.exists(): if config_path.exists():
try: try:
import yaml import yaml
with open(config_path, 'r', encoding='utf-8') as f: with open(config_path, 'r', encoding='utf-8') as f:
config = yaml.safe_load(f) cfg = yaml.safe_load(f) or {}
if config and 'server' in config and 'port' in config['server']: return str(cfg.get('server', {}).get('port', 8000))
return str(config['server']['port'])
except Exception: except Exception:
pass # Fall through to default pass
# Priority 3: Default
return "8000" return "8000"
def main():
print("🚀 Starting NoteDiscovery...\n")
# Check if requirements are installed def main():
try: try:
import fastapi import fastapi # noqa: F401
import uvicorn import uvicorn # noqa: F401
except ImportError: except ImportError:
print("📦 Installing dependencies...") print("Installing dependencies...")
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"]) subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"])
# Create data directories
Path("data").mkdir(parents=True, exist_ok=True)
Path("plugins").mkdir(parents=True, exist_ok=True)
# Get port from config or environment
port = get_port() port = get_port()
print(f"📝 NoteDiscovery → http://localhost:{port} (Ctrl+C to stop)")
print()
print("✓ Dependencies installed")
print("✓ Directories created")
print("\n" + "="*50)
print("🎉 NoteDiscovery is running!")
print("="*50)
print(f"\n📝 Open your browser to: http://localhost:{port}")
print("\n💡 Tips:")
print(" - Press Ctrl+C to stop the server")
print(" - Your notes are in ./data/")
print(" - Plugins go in ./plugins/")
print(f" - Change port with: PORT={port} python run.py")
print("\n" + "="*50 + "\n")
# Run the application
subprocess.call([ subprocess.call([
sys.executable, "-m", "uvicorn", sys.executable, "-m", "uvicorn",
"backend.main:app", "backend.main:app",