Merge pull request #245 from gamosoft/features/global-index
Features/global index
This commit is contained in:
commit
b4952df6be
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
309
backend/main.py
309
backend/main.py
|
|
@ -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(
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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', '')
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
602
backend/utils.py
602
backend/utils.py
|
|
@ -1,18 +1,29 @@
|
||||||
"""
|
"""
|
||||||
Utility functions for file operations, search, and markdown processing
|
Utility functions for file operations, search, and markdown processing.
|
||||||
|
|
||||||
|
Heavy work (backlinks, graph, full-text search, tag aggregation) is delegated
|
||||||
|
to the in-memory index in note_index.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import traceback
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Optional, Tuple, Any, TypeVar, Callable
|
from typing import List, Dict, Optional, Tuple, Any, TypeVar, Callable
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from . import note_index
|
||||||
|
from .note_index import NoteRecord, extract_links_from_content
|
||||||
|
|
||||||
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Pagination Support
|
# Pagination Support
|
||||||
|
|
@ -111,14 +122,23 @@ def paginate(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# In-memory cache for parsed tags
|
# ============================================================================
|
||||||
# Format: {file_path: (mtime, tags)}
|
# In-memory caches
|
||||||
_tag_cache: Dict[str, Tuple[float, List[str]]] = {}
|
|
||||||
|
|
||||||
# Notes tree scan cache (TTL).
|
|
||||||
#
|
#
|
||||||
# This avoids repeated full-directory walks when multiple endpoints (or the UI)
|
# Two layers:
|
||||||
# request indexes in quick succession.
|
# _tag_cache / _links_cache : per-file, mtime-keyed. Avoid re-parsing the
|
||||||
|
# same file on warm scans. Used by get_tags_and_links_cached.
|
||||||
|
# _SCAN_WALK_CACHE : per-scan TTL cache. Avoids repeated full-
|
||||||
|
# directory walks when several endpoints fire in quick succession.
|
||||||
|
#
|
||||||
|
# All three are invalidated on every mutation (save/delete/move). The
|
||||||
|
# note_index in note_index.py is the system of record for derived data
|
||||||
|
# (links, backlinks, tags-by-name, search) — these caches just exist to
|
||||||
|
# avoid double-reading files we already parsed.
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
_tag_cache: Dict[str, Tuple[float, List[str]]] = {}
|
||||||
|
_links_cache: Dict[str, Tuple[float, Dict[str, List[str]]]] = {}
|
||||||
|
|
||||||
_SCAN_WALK_CACHE_LOCK = threading.Lock()
|
_SCAN_WALK_CACHE_LOCK = threading.Lock()
|
||||||
_SCAN_WALK_CACHE_TTL_SECONDS = 1.0
|
_SCAN_WALK_CACHE_TTL_SECONDS = 1.0
|
||||||
|
|
@ -144,6 +164,63 @@ def _scan_cache_set(key: Tuple[str, bool], value: Tuple[List[Dict], List[str]])
|
||||||
_SCAN_WALK_CACHE[key] = (time.monotonic(), value)
|
_SCAN_WALK_CACHE[key] = (time.monotonic(), value)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_cache_invalidate() -> None:
|
||||||
|
"""Drop the TTL scan cache. Called from every mutation handler."""
|
||||||
|
with _SCAN_WALK_CACHE_LOCK:
|
||||||
|
_SCAN_WALK_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_index_built(notes_dir: str) -> None:
|
||||||
|
"""Trigger a fresh scan when the NoteIndex hasn't been populated yet.
|
||||||
|
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=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tags_and_links_cached(
|
||||||
|
file_path: Path,
|
||||||
|
rel_path: Optional[str] = None,
|
||||||
|
) -> Tuple[List[str], Dict[str, List[str]]]:
|
||||||
|
"""Fused tags + raw-links extraction, mtime-cached. Tries in-process
|
||||||
|
per-file caches first, then the NoteIndex (when rel_path is provided),
|
||||||
|
then reads the file."""
|
||||||
|
try:
|
||||||
|
mtime = file_path.stat().st_mtime
|
||||||
|
file_key = str(file_path)
|
||||||
|
|
||||||
|
tag_cached = _tag_cache.get(file_key)
|
||||||
|
link_cached = _links_cache.get(file_key)
|
||||||
|
tags_ok = tag_cached is not None and tag_cached[0] == mtime
|
||||||
|
links_ok = link_cached is not None and link_cached[0] == mtime
|
||||||
|
|
||||||
|
if tags_ok and links_ok:
|
||||||
|
return tag_cached[1], link_cached[1]
|
||||||
|
|
||||||
|
if rel_path is not None:
|
||||||
|
indexed = note_index.try_get_extraction(rel_path, mtime)
|
||||||
|
if indexed is not None:
|
||||||
|
idx_tags, idx_links = indexed
|
||||||
|
_tag_cache[file_key] = (mtime, idx_tags)
|
||||||
|
_links_cache[file_key] = (mtime, idx_links)
|
||||||
|
return idx_tags, idx_links
|
||||||
|
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
tags = tag_cached[1] if tags_ok else parse_tags(content)
|
||||||
|
links = link_cached[1] if links_ok else extract_links_from_content(content)
|
||||||
|
|
||||||
|
if not tags_ok:
|
||||||
|
_tag_cache[file_key] = (mtime, tags)
|
||||||
|
if not links_ok:
|
||||||
|
_links_cache[file_key] = (mtime, links)
|
||||||
|
|
||||||
|
return tags, links
|
||||||
|
except Exception:
|
||||||
|
return [], {"wikilinks": [], "mdlinks": []}
|
||||||
|
|
||||||
|
|
||||||
def validate_path_security(notes_dir: str, path: Path) -> bool:
|
def validate_path_security(notes_dir: str, path: Path) -> bool:
|
||||||
"""
|
"""
|
||||||
Validate that a path is within the notes directory (security check).
|
Validate that a path is within the notes directory (security check).
|
||||||
|
|
@ -175,15 +252,12 @@ def ensure_directories(config: dict):
|
||||||
|
|
||||||
|
|
||||||
def create_folder(notes_dir: str, folder_path: str) -> bool:
|
def create_folder(notes_dir: str, folder_path: str) -> bool:
|
||||||
"""Create a new folder in the notes directory"""
|
"""Create a new folder in the notes directory."""
|
||||||
full_path = Path(notes_dir) / folder_path
|
full_path = Path(notes_dir) / folder_path
|
||||||
|
|
||||||
# Security check
|
|
||||||
if not validate_path_security(notes_dir, full_path):
|
if not validate_path_security(notes_dir, full_path):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
full_path.mkdir(parents=True, exist_ok=True)
|
full_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
_scan_cache_invalidate()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -218,11 +292,13 @@ def scan_notes_fast_walk(notes_dir: str, use_cache: bool = True, include_media:
|
||||||
_scan_cache_set(cache_key, normalized_value)
|
_scan_cache_set(cache_key, normalized_value)
|
||||||
return normalized_value
|
return normalized_value
|
||||||
|
|
||||||
|
# Walk the vault once. Tag/link extraction is parallelized below across
|
||||||
|
# the markdown files we collected here.
|
||||||
notes: List[Dict] = []
|
notes: List[Dict] = []
|
||||||
folders_set = set()
|
folders_set = set()
|
||||||
|
md_to_extract: List[Tuple[int, Path, str]] = [] # (index, full_path, rel_path)
|
||||||
|
|
||||||
for root, dirnames, filenames in os.walk(notes_path):
|
for root, dirnames, filenames in os.walk(notes_path):
|
||||||
# Skip descending into dot-dirs
|
|
||||||
dirnames[:] = [d for d in dirnames if not d.startswith('.')]
|
dirnames[:] = [d for d in dirnames if not d.startswith('.')]
|
||||||
|
|
||||||
root_path = Path(root)
|
root_path = Path(root)
|
||||||
|
|
@ -244,22 +320,61 @@ def scan_notes_fast_walk(notes_dir: str, use_cache: bool = True, include_media:
|
||||||
media_type = get_media_type(filename) if include_media else None
|
media_type = get_media_type(filename) if include_media else None
|
||||||
is_markdown = full_path.suffix.lower() == '.md'
|
is_markdown = full_path.suffix.lower() == '.md'
|
||||||
should_include = is_markdown or (include_media and media_type is not None)
|
should_include = is_markdown or (include_media and media_type is not None)
|
||||||
|
|
||||||
if not should_include:
|
if not should_include:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
folder = relative_path.parent.as_posix()
|
folder = relative_path.parent.as_posix()
|
||||||
# Get tags for this note (cached)
|
rel_str = relative_path.as_posix()
|
||||||
tags = get_tags_cached(full_path) if is_markdown else []
|
|
||||||
notes.append({
|
notes.append({
|
||||||
"name": full_path.stem,
|
"name": full_path.stem,
|
||||||
"path": relative_path.as_posix(),
|
"path": rel_str,
|
||||||
"folder": "" if folder == "." else folder,
|
"folder": "" if folder == "." else folder,
|
||||||
"modified": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
|
"modified": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
|
||||||
"size": st.st_size,
|
"size": st.st_size,
|
||||||
"type": media_type if media_type else "note",
|
"type": media_type if media_type else "note",
|
||||||
"tags": tags,
|
"tags": [],
|
||||||
|
"_mtime": st.st_mtime, # internal — popped before returning
|
||||||
})
|
})
|
||||||
|
if is_markdown:
|
||||||
|
md_to_extract.append((len(notes) - 1, full_path, rel_str))
|
||||||
|
|
||||||
|
# Tag + raw-link extraction in parallel. Search-index terms are built
|
||||||
|
# later, lazily, on the first /api/search call.
|
||||||
|
sources_raw: Dict[str, Dict[str, List[str]]] = {}
|
||||||
|
if md_to_extract:
|
||||||
|
path_pairs = [(full, rel) for (_, full, rel) in md_to_extract]
|
||||||
|
if len(md_to_extract) >= 50:
|
||||||
|
workers = min(8, (os.cpu_count() or 4))
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||||
|
extraction = list(ex.map(
|
||||||
|
lambda pair: get_tags_and_links_cached(pair[0], pair[1]),
|
||||||
|
path_pairs,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
extraction = [get_tags_and_links_cached(full, rel) for (full, rel) in path_pairs]
|
||||||
|
|
||||||
|
for (idx, _full, rel_str), (tags, links) in zip(md_to_extract, extraction):
|
||||||
|
notes[idx]["tags"] = tags
|
||||||
|
sources_raw[rel_str] = links
|
||||||
|
|
||||||
|
# Push the result into the NoteIndex. Short-circuits on warm scans.
|
||||||
|
notes_meta = [
|
||||||
|
NoteRecord(
|
||||||
|
path=n["path"],
|
||||||
|
name=n["name"],
|
||||||
|
folder=n["folder"],
|
||||||
|
modified=n["modified"],
|
||||||
|
size=n["size"],
|
||||||
|
type=n["type"],
|
||||||
|
mtime=n["_mtime"],
|
||||||
|
tags=tuple(n["tags"]),
|
||||||
|
)
|
||||||
|
for n in notes
|
||||||
|
]
|
||||||
|
note_index.populate_from_scan(notes_meta, folders_set, sources_raw)
|
||||||
|
|
||||||
|
for n in notes:
|
||||||
|
n.pop("_mtime", None)
|
||||||
|
|
||||||
value = (sorted(notes, key=lambda x: x.get('modified', ''), reverse=True), sorted(folders_set))
|
value = (sorted(notes, key=lambda x: x.get('modified', ''), reverse=True), sorted(folders_set))
|
||||||
if use_cache:
|
if use_cache:
|
||||||
|
|
@ -267,132 +382,106 @@ def scan_notes_fast_walk(notes_dir: str, use_cache: bool = True, include_media:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def move_note(notes_dir: str, old_path: str, new_path: str) -> tuple[bool, str]:
|
def move_note(notes_dir: str, old_path: str, new_path: str) -> tuple[bool, str]:
|
||||||
"""Move a note to a different location
|
"""Move a note. Returns (success, error_message)."""
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (success: bool, error_message: str)
|
|
||||||
"""
|
|
||||||
old_full_path = Path(notes_dir) / old_path
|
old_full_path = Path(notes_dir) / old_path
|
||||||
new_full_path = Path(notes_dir) / new_path
|
new_full_path = Path(notes_dir) / new_path
|
||||||
|
|
||||||
# Security checks
|
|
||||||
if not validate_path_security(notes_dir, old_full_path):
|
if not validate_path_security(notes_dir, old_full_path):
|
||||||
return False, "Invalid source path"
|
return False, "Invalid source path"
|
||||||
if not validate_path_security(notes_dir, new_full_path):
|
if not validate_path_security(notes_dir, new_full_path):
|
||||||
return False, "Invalid destination path"
|
return False, "Invalid destination path"
|
||||||
|
|
||||||
if not old_full_path.exists():
|
if not old_full_path.exists():
|
||||||
return False, f"Source note does not exist: {old_path}"
|
return False, f"Source note does not exist: {old_path}"
|
||||||
|
|
||||||
# Check if target already exists (prevent overwriting)
|
|
||||||
if new_full_path.exists():
|
if new_full_path.exists():
|
||||||
return False, f"A note already exists at: {new_path}"
|
return False, f"A note already exists at: {new_path}"
|
||||||
|
|
||||||
# Invalidate cache for old path
|
_drop_path_caches(old_full_path)
|
||||||
old_key = str(old_full_path)
|
|
||||||
if old_key in _tag_cache:
|
|
||||||
del _tag_cache[old_key]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create parent directory if needed
|
|
||||||
new_full_path.parent.mkdir(parents=True, exist_ok=True)
|
new_full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Move the file
|
|
||||||
old_full_path.rename(new_full_path)
|
old_full_path.rename(new_full_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"Failed to move file: {str(e)}"
|
return False, f"Failed to move file: {str(e)}"
|
||||||
|
|
||||||
# Note: We don't automatically delete empty folders to preserve user's folder structure
|
note_index.on_note_renamed(notes_dir, old_full_path, new_full_path)
|
||||||
|
_scan_cache_invalidate()
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
def move_folder(notes_dir: str, old_path: str, new_path: str) -> tuple[bool, str]:
|
def move_folder(notes_dir: str, old_path: str, new_path: str) -> tuple[bool, str]:
|
||||||
"""Move a folder to a different location
|
"""Move a folder. Returns (success, error_message)."""
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (success: bool, error_message: str)
|
|
||||||
"""
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
old_full_path = Path(notes_dir) / old_path
|
old_full_path = Path(notes_dir) / old_path
|
||||||
new_full_path = Path(notes_dir) / new_path
|
new_full_path = Path(notes_dir) / new_path
|
||||||
|
|
||||||
# Security checks
|
|
||||||
if not validate_path_security(notes_dir, old_full_path):
|
if not validate_path_security(notes_dir, old_full_path):
|
||||||
return False, "Invalid source path"
|
return False, "Invalid source path"
|
||||||
if not validate_path_security(notes_dir, new_full_path):
|
if not validate_path_security(notes_dir, new_full_path):
|
||||||
return False, "Invalid destination path"
|
return False, "Invalid destination path"
|
||||||
|
|
||||||
if not old_full_path.exists() or not old_full_path.is_dir():
|
if not old_full_path.exists() or not old_full_path.is_dir():
|
||||||
return False, f"Source folder does not exist: {old_path}"
|
return False, f"Source folder does not exist: {old_path}"
|
||||||
|
|
||||||
# Check if target already exists
|
|
||||||
if new_full_path.exists():
|
if new_full_path.exists():
|
||||||
return False, f"A folder already exists at: {new_path}"
|
return False, f"A folder already exists at: {new_path}"
|
||||||
|
|
||||||
# Invalidate cache for all notes in this folder
|
_drop_prefix_caches(old_full_path)
|
||||||
global _tag_cache
|
|
||||||
old_path_str = str(old_full_path)
|
|
||||||
keys_to_delete = [key for key in _tag_cache.keys() if key.startswith(old_path_str)]
|
|
||||||
for key in keys_to_delete:
|
|
||||||
del _tag_cache[key]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create parent directory if needed
|
|
||||||
new_full_path.parent.mkdir(parents=True, exist_ok=True)
|
new_full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Move the folder
|
|
||||||
shutil.move(str(old_full_path), str(new_full_path))
|
shutil.move(str(old_full_path), str(new_full_path))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"Failed to move folder: {str(e)}"
|
return False, f"Failed to move folder: {str(e)}"
|
||||||
|
|
||||||
# Note: We don't automatically delete empty folders to preserve user's folder structure
|
note_index.on_folder_renamed(notes_dir, old_full_path, new_full_path)
|
||||||
|
_scan_cache_invalidate()
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
def rename_folder(notes_dir: str, old_path: str, new_path: str) -> tuple[bool, str]:
|
def rename_folder(notes_dir: str, old_path: str, new_path: str) -> tuple[bool, str]:
|
||||||
"""Rename a folder (same as move but for clarity)"""
|
"""Rename a folder (same as move, named for clarity)."""
|
||||||
return move_folder(notes_dir, old_path, new_path)
|
return move_folder(notes_dir, old_path, new_path)
|
||||||
|
|
||||||
|
|
||||||
def delete_folder(notes_dir: str, folder_path: str) -> bool:
|
def delete_folder(notes_dir: str, folder_path: str) -> bool:
|
||||||
"""Delete a folder and all its contents"""
|
"""Delete a folder and all its contents."""
|
||||||
try:
|
try:
|
||||||
full_path = Path(notes_dir) / folder_path
|
full_path = Path(notes_dir) / folder_path
|
||||||
|
|
||||||
# Security check: ensure the path is within notes_dir
|
|
||||||
if not validate_path_security(notes_dir, full_path):
|
if not validate_path_security(notes_dir, full_path):
|
||||||
print(f"Security: Path is outside notes directory: {full_path}")
|
logger.warning("Security: Path is outside notes directory: %s", full_path)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not full_path.exists():
|
if not full_path.exists():
|
||||||
print(f"Folder does not exist: {full_path}")
|
logger.warning("Folder does not exist: %s", full_path)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not full_path.is_dir():
|
if not full_path.is_dir():
|
||||||
print(f"Path is not a directory: {full_path}")
|
logger.warning("Path is not a directory: %s", full_path)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Invalidate cache for all notes in this folder
|
_drop_prefix_caches(full_path)
|
||||||
global _tag_cache
|
|
||||||
folder_path_str = str(full_path)
|
|
||||||
keys_to_delete = [key for key in _tag_cache.keys() if key.startswith(folder_path_str)]
|
|
||||||
for key in keys_to_delete:
|
|
||||||
del _tag_cache[key]
|
|
||||||
|
|
||||||
# Delete the folder and all its contents
|
|
||||||
shutil.rmtree(full_path)
|
shutil.rmtree(full_path)
|
||||||
print(f"Successfully deleted folder: {full_path}")
|
note_index.on_folder_deleted(notes_dir, full_path)
|
||||||
|
_scan_cache_invalidate()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error deleting folder '{folder_path}': {e}")
|
logger.error("Error deleting folder '%s': %s", folder_path, e)
|
||||||
import traceback
|
logger.error(traceback.format_exc())
|
||||||
traceback.print_exc()
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_path_caches(full_path: Path) -> None:
|
||||||
|
"""Evict the per-file mtime caches for a single note."""
|
||||||
|
key = str(full_path)
|
||||||
|
_tag_cache.pop(key, None)
|
||||||
|
_links_cache.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_prefix_caches(folder_full_path: Path) -> None:
|
||||||
|
"""Evict per-file mtime caches for every entry under a folder."""
|
||||||
|
prefix = str(folder_full_path)
|
||||||
|
for k in [k for k in _tag_cache if k.startswith(prefix)]:
|
||||||
|
_tag_cache.pop(k, None)
|
||||||
|
for k in [k for k in _links_cache if k.startswith(prefix)]:
|
||||||
|
_links_cache.pop(k, None)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_note_content(notes_dir: str, note_path: str) -> Optional[str]:
|
def get_note_content(notes_dir: str, note_path: str) -> Optional[str]:
|
||||||
|
|
@ -411,110 +500,112 @@ def get_note_content(notes_dir: str, note_path: str) -> Optional[str]:
|
||||||
|
|
||||||
|
|
||||||
def save_note(notes_dir: str, note_path: str, content: str) -> bool:
|
def save_note(notes_dir: str, note_path: str, content: str) -> bool:
|
||||||
"""Save or update a note"""
|
"""Save or update a note."""
|
||||||
full_path = Path(notes_dir) / note_path
|
full_path = Path(notes_dir) / note_path
|
||||||
|
|
||||||
# Ensure .md extension
|
|
||||||
if not note_path.endswith('.md'):
|
if not note_path.endswith('.md'):
|
||||||
full_path = full_path.with_suffix('.md')
|
full_path = full_path.with_suffix('.md')
|
||||||
|
|
||||||
# Security check
|
|
||||||
if not validate_path_security(notes_dir, full_path):
|
if not validate_path_security(notes_dir, full_path):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Create parent directories if needed
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
with open(full_path, 'w', encoding='utf-8') as f:
|
with open(full_path, 'w', encoding='utf-8') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|
||||||
|
# Refresh the per-file mtime caches with what we just wrote, so the next
|
||||||
|
# scan doesn't re-parse this file. extract_links + parse_tags + save are
|
||||||
|
# tiny relative to the file write.
|
||||||
|
try:
|
||||||
|
mtime = full_path.stat().st_mtime
|
||||||
|
file_key = str(full_path)
|
||||||
|
_tag_cache[file_key] = (mtime, parse_tags(content))
|
||||||
|
_links_cache[file_key] = (mtime, extract_links_from_content(content))
|
||||||
|
except Exception:
|
||||||
|
pass # caches are best-effort
|
||||||
|
|
||||||
|
note_index.on_note_saved(notes_dir, full_path, content)
|
||||||
|
_scan_cache_invalidate()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def delete_note(notes_dir: str, note_path: str) -> bool:
|
def delete_note(notes_dir: str, note_path: str) -> bool:
|
||||||
"""Delete a note"""
|
"""Delete a note."""
|
||||||
full_path = Path(notes_dir) / note_path
|
full_path = Path(notes_dir) / note_path
|
||||||
|
|
||||||
if not full_path.exists():
|
if not full_path.exists():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Security check
|
|
||||||
if not validate_path_security(notes_dir, full_path):
|
if not validate_path_security(notes_dir, full_path):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Invalidate cache for this note
|
_drop_path_caches(full_path)
|
||||||
file_key = str(full_path)
|
|
||||||
if file_key in _tag_cache:
|
|
||||||
del _tag_cache[file_key]
|
|
||||||
|
|
||||||
full_path.unlink()
|
full_path.unlink()
|
||||||
|
note_index.on_note_deleted(notes_dir, full_path)
|
||||||
# Note: We don't automatically delete empty folders to preserve user's folder structure
|
_scan_cache_invalidate()
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def search_notes(notes_dir: str, query: str) -> List[Dict]:
|
def search_notes(notes_dir: str, query: str) -> List[Dict]:
|
||||||
"""
|
"""Full-text search through note contents. Narrow the candidate set via
|
||||||
Full-text search through note contents only.
|
the inverted index, then run the snippet extractor on each candidate."""
|
||||||
Does NOT search in file names, folder names, or paths - only note content.
|
|
||||||
Uses character-based context extraction with highlighted matches.
|
|
||||||
"""
|
|
||||||
from html import escape
|
from html import escape
|
||||||
results = []
|
results: List[Dict] = []
|
||||||
notes, _folders = scan_notes_fast_walk(notes_dir, include_media=False)
|
|
||||||
|
|
||||||
for note in notes:
|
ensure_index_built(notes_dir)
|
||||||
md_file = Path(notes_dir) / note["path"]
|
note_index.ensure_search_index(notes_dir)
|
||||||
|
candidates = note_index.get_search_candidates(query)
|
||||||
|
|
||||||
|
idx = note_index.get_index()
|
||||||
|
if candidates is None:
|
||||||
|
# Query too short to tokenize — iterate every indexed note instead.
|
||||||
|
candidate_records = idx.all_note_records()
|
||||||
|
else:
|
||||||
|
candidate_records = [(p, idx.get_note_record(p)) for p in candidates]
|
||||||
|
candidate_records = [(p, r) for (p, r) in candidate_records if r is not None and r.type == "note"]
|
||||||
|
|
||||||
|
candidate_records.sort(key=lambda pr: pr[1].mtime, reverse=True)
|
||||||
|
iterable = [{"path": p, "name": r.name} for (p, r) in candidate_records]
|
||||||
|
|
||||||
|
for note in iterable:
|
||||||
|
path = note["path"]
|
||||||
|
md_file = Path(notes_dir) / path
|
||||||
try:
|
try:
|
||||||
with open(md_file, 'r', encoding='utf-8') as f:
|
with open(md_file, 'r', encoding='utf-8') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
# Find all matches using regex (case-insensitive)
|
|
||||||
matches = list(re.finditer(re.escape(query), content, re.IGNORECASE))
|
matches = list(re.finditer(re.escape(query), content, re.IGNORECASE))
|
||||||
|
if not matches:
|
||||||
|
continue
|
||||||
|
|
||||||
if matches:
|
|
||||||
matched_lines = []
|
matched_lines = []
|
||||||
|
for match in matches[:3]:
|
||||||
for match in matches[:3]: # Limit to 3 matches per file
|
|
||||||
start_index = match.start()
|
start_index = match.start()
|
||||||
end_index = match.end()
|
end_index = match.end()
|
||||||
matched_text = match.group() # Preserve original case
|
matched_text = match.group()
|
||||||
|
|
||||||
# Create slice window: ±15 characters around match
|
|
||||||
context_start = max(0, start_index - 15)
|
context_start = max(0, start_index - 15)
|
||||||
context_end = min(len(content), end_index + 15)
|
context_end = min(len(content), end_index + 15)
|
||||||
|
|
||||||
# Extract and clean parts (newlines → spaces)
|
|
||||||
before = escape(content[context_start:start_index].replace('\n', ' '))
|
before = escape(content[context_start:start_index].replace('\n', ' '))
|
||||||
after = escape(content[end_index:context_end].replace('\n', ' '))
|
after = escape(content[end_index:context_end].replace('\n', ' '))
|
||||||
matched_clean = escape(matched_text.replace('\n', ' '))
|
matched_clean = escape(matched_text.replace('\n', ' '))
|
||||||
|
|
||||||
# Build snippet with <mark> highlight (styled via CSS)
|
|
||||||
snippet = f'{before}<mark class="search-highlight">{matched_clean}</mark>{after}'
|
snippet = f'{before}<mark class="search-highlight">{matched_clean}</mark>{after}'
|
||||||
|
|
||||||
# Add ellipsis if truncated at start
|
|
||||||
if context_start > 0:
|
if context_start > 0:
|
||||||
snippet = '...' + snippet
|
snippet = '...' + snippet
|
||||||
|
|
||||||
# Add ellipsis if truncated at end
|
|
||||||
if context_end < len(content):
|
if context_end < len(content):
|
||||||
snippet = snippet + '...'
|
snippet = snippet + '...'
|
||||||
|
|
||||||
# Calculate line number by counting newlines up to match start
|
|
||||||
line_number = content.count('\n', 0, start_index) + 1
|
line_number = content.count('\n', 0, start_index) + 1
|
||||||
|
|
||||||
matched_lines.append({
|
matched_lines.append({
|
||||||
"line_number": line_number,
|
"line_number": line_number,
|
||||||
"context": snippet
|
"context": snippet,
|
||||||
})
|
})
|
||||||
|
|
||||||
relative_path = Path(note["path"])
|
relative_path = Path(path)
|
||||||
results.append({
|
results.append({
|
||||||
"name": md_file.stem,
|
"name": md_file.stem,
|
||||||
"path": str(relative_path.as_posix()),
|
"path": str(relative_path.as_posix()),
|
||||||
"folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "",
|
"folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "",
|
||||||
"matches": matched_lines
|
"matches": matched_lines,
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
@ -644,9 +735,10 @@ def save_uploaded_image(
|
||||||
try:
|
try:
|
||||||
with open(full_path, "wb") as f:
|
with open(full_path, "wb") as f:
|
||||||
f.write(file_data)
|
f.write(file_data)
|
||||||
|
_scan_cache_invalidate()
|
||||||
return str(full_path.relative_to(base).as_posix())
|
return str(full_path.relative_to(base).as_posix())
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
print(f"Error saving image: {e}")
|
logger.error("Error saving image: %s", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
sanitized_name = sanitize_filename(filename)
|
sanitized_name = sanitize_filename(filename)
|
||||||
|
|
@ -658,14 +750,15 @@ def save_uploaded_image(
|
||||||
attachments_dir.mkdir(parents=True, exist_ok=True)
|
attachments_dir.mkdir(parents=True, exist_ok=True)
|
||||||
full_path = attachments_dir / final_filename
|
full_path = attachments_dir / final_filename
|
||||||
if not validate_path_security(notes_dir, full_path):
|
if not validate_path_security(notes_dir, full_path):
|
||||||
print(f"Security: Attempted to save image outside notes directory: {full_path}")
|
logger.warning("Security: Attempted to save image outside notes directory: %s", full_path)
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
with open(full_path, "wb") as f:
|
with open(full_path, "wb") as f:
|
||||||
f.write(file_data)
|
f.write(file_data)
|
||||||
|
_scan_cache_invalidate()
|
||||||
return str(full_path.relative_to(base).as_posix())
|
return str(full_path.relative_to(base).as_posix())
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
print(f"Error saving image: {e}")
|
logger.error("Error saving image: %s", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -781,110 +874,35 @@ def parse_tags(content: str) -> List[str]:
|
||||||
return sorted(list(set(tags)))
|
return sorted(list(set(tags)))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If parsing fails, return empty list
|
logger.error("Error parsing tags: %s", e)
|
||||||
print(f"Error parsing tags: {e}")
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def get_tags_cached(file_path: Path) -> List[str]:
|
|
||||||
"""
|
|
||||||
Get tags for a file with caching based on modification time.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_path: Path to the markdown file
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of tags from the file (cached if mtime unchanged)
|
|
||||||
"""
|
|
||||||
global _tag_cache
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get current modification time
|
|
||||||
mtime = file_path.stat().st_mtime
|
|
||||||
file_key = str(file_path)
|
|
||||||
|
|
||||||
# Check cache
|
|
||||||
if file_key in _tag_cache:
|
|
||||||
cached_mtime, cached_tags = _tag_cache[file_key]
|
|
||||||
if cached_mtime == mtime:
|
|
||||||
# Cache hit! Return cached tags
|
|
||||||
return cached_tags
|
|
||||||
|
|
||||||
# Cache miss or stale - parse tags
|
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
|
||||||
content = f.read()
|
|
||||||
tags = parse_tags(content)
|
|
||||||
|
|
||||||
# Update cache
|
|
||||||
_tag_cache[file_key] = (mtime, tags)
|
|
||||||
return tags
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
# If anything fails, return empty list
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def clear_tag_cache():
|
|
||||||
"""Clear the tag cache (useful for testing or manual cache invalidation)"""
|
|
||||||
global _tag_cache
|
|
||||||
_tag_cache.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def get_all_tags(notes_dir: str) -> Dict[str, int]:
|
def get_all_tags(notes_dir: str) -> Dict[str, int]:
|
||||||
"""
|
"""All tags in the vault with note counts."""
|
||||||
Get all tags used across all notes with their count (cached).
|
ensure_index_built(notes_dir)
|
||||||
|
return note_index.get_all_tags()
|
||||||
Args:
|
|
||||||
notes_dir: Directory containing notes
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary mapping tag names to note counts
|
|
||||||
"""
|
|
||||||
tag_counts = {}
|
|
||||||
notes, _folders = scan_notes_fast_walk(notes_dir, include_media=False)
|
|
||||||
|
|
||||||
for note in notes:
|
|
||||||
md_file = Path(notes_dir) / note["path"]
|
|
||||||
# Get tags using cache
|
|
||||||
tags = get_tags_cached(md_file)
|
|
||||||
|
|
||||||
for tag in tags:
|
|
||||||
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
|
||||||
|
|
||||||
return dict(sorted(tag_counts.items()))
|
|
||||||
|
|
||||||
|
|
||||||
def get_notes_by_tag(notes_dir: str, tag: str) -> List[Dict]:
|
def get_notes_by_tag(notes_dir: str, tag: str) -> List[Dict]:
|
||||||
"""
|
"""All notes carrying `tag` (case-insensitive)."""
|
||||||
Get all notes that have a specific tag (cached).
|
ensure_index_built(notes_dir)
|
||||||
|
idx = note_index.get_index()
|
||||||
Args:
|
records = [idx.get_note_record(p) for p in note_index.get_paths_for_tag(tag.lower())]
|
||||||
notes_dir: Directory containing notes
|
matching = [
|
||||||
tag: Tag to filter by (case-insensitive)
|
{
|
||||||
|
"name": r.name,
|
||||||
Returns:
|
"path": r.path,
|
||||||
List of note dictionaries matching the tag
|
"folder": r.folder,
|
||||||
"""
|
"modified": r.modified,
|
||||||
matching_notes = []
|
"size": r.size,
|
||||||
tag_lower = tag.lower()
|
"tags": list(r.tags),
|
||||||
notes, _folders = scan_notes_fast_walk(notes_dir, include_media=False)
|
}
|
||||||
|
for r in records
|
||||||
for note in notes:
|
if r is not None and r.type == "note"
|
||||||
md_file = Path(notes_dir) / note["path"]
|
]
|
||||||
# Get tags using cache
|
matching.sort(key=lambda n: n["modified"], reverse=True)
|
||||||
tags = get_tags_cached(md_file)
|
return matching
|
||||||
|
|
||||||
if tag_lower in tags:
|
|
||||||
matching_notes.append({
|
|
||||||
"name": note["name"],
|
|
||||||
"path": note["path"],
|
|
||||||
"folder": note["folder"],
|
|
||||||
"modified": note["modified"],
|
|
||||||
"size": note["size"],
|
|
||||||
"tags": tags
|
|
||||||
})
|
|
||||||
|
|
||||||
return matching_notes
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
@ -909,15 +927,14 @@ def get_templates(notes_dir: str) -> List[Dict]:
|
||||||
|
|
||||||
# Security check: ensure _templates folder is within notes directory
|
# Security check: ensure _templates folder is within notes directory
|
||||||
if not validate_path_security(notes_dir, templates_path):
|
if not validate_path_security(notes_dir, templates_path):
|
||||||
print(f"Security: Templates directory is outside notes directory: {templates_path}")
|
logger.warning("Security: Templates directory is outside notes directory: %s", templates_path)
|
||||||
return templates
|
return templates
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for template_file in templates_path.glob("*.md"):
|
for template_file in templates_path.glob("*.md"):
|
||||||
try:
|
try:
|
||||||
# Security check: ensure each template is within notes directory
|
|
||||||
if not validate_path_security(notes_dir, template_file):
|
if not validate_path_security(notes_dir, template_file):
|
||||||
print(f"Security: Skipping template outside notes directory: {template_file}")
|
logger.warning("Security: Skipping template outside notes directory: %s", template_file)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
stat = template_file.stat()
|
stat = template_file.stat()
|
||||||
|
|
@ -927,10 +944,10 @@ def get_templates(notes_dir: str) -> List[Dict]:
|
||||||
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
|
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error reading template {template_file}: {e}")
|
logger.error("Error reading template %s: %s", template_file, e)
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error accessing templates directory: {e}")
|
logger.error("Error accessing templates directory: %s", e)
|
||||||
|
|
||||||
return sorted(templates, key=lambda x: x['name'])
|
return sorted(templates, key=lambda x: x['name'])
|
||||||
|
|
||||||
|
|
@ -953,14 +970,14 @@ def get_template_content(notes_dir: str, template_name: str) -> Optional[str]:
|
||||||
|
|
||||||
# Security check: ensure template is within notes directory
|
# Security check: ensure template is within notes directory
|
||||||
if not validate_path_security(notes_dir, template_path):
|
if not validate_path_security(notes_dir, template_path):
|
||||||
print(f"Security: Template path is outside notes directory: {template_path}")
|
logger.warning("Security: Template path is outside notes directory: %s", template_path)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(template_path, 'r', encoding='utf-8') as f:
|
with open(template_path, 'r', encoding='utf-8') as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error reading template {template_name}: {e}")
|
logger.error("Error reading template %s: %s", template_name, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1041,69 +1058,38 @@ def apply_template_placeholders(content: str, note_path: str) -> str:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def get_backlinks(notes_dir: str, target_note_path: str) -> List[Dict]:
|
def _extract_backlink_references(
|
||||||
|
notes_dir: str,
|
||||||
|
source_path: str,
|
||||||
|
target_path: str,
|
||||||
|
target_path_lower: str,
|
||||||
|
target_path_no_ext_lower: str,
|
||||||
|
wikilink_refs: set,
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""Read one source file and pull line-level references that point to target.
|
||||||
|
|
||||||
|
Same regex + resolution rules as the legacy get_backlinks loop body; just
|
||||||
|
factored out so both the index-driven and the legacy code paths use the
|
||||||
|
exact same context-extraction logic.
|
||||||
"""
|
"""
|
||||||
Find all notes that link TO the specified note (reverse links / backlinks).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
notes_dir: Base directory containing notes
|
|
||||||
target_note_path: Path of the note to find backlinks for
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of backlink objects with path, context, and line_number
|
|
||||||
"""
|
|
||||||
backlinks = []
|
|
||||||
notes, _folders = scan_notes_fast_walk(notes_dir, include_media=False)
|
|
||||||
|
|
||||||
# Normalize target path for matching
|
|
||||||
target_path = target_note_path
|
|
||||||
target_path_lower = target_path.lower()
|
|
||||||
target_path_no_ext = target_path.replace('.md', '')
|
|
||||||
target_path_no_ext_lower = target_path_no_ext.lower()
|
|
||||||
target_name = Path(target_path).stem.lower()
|
|
||||||
|
|
||||||
# For wikilinks: global name matching (find note anywhere by name)
|
|
||||||
wikilink_refs = {
|
|
||||||
target_path_lower,
|
|
||||||
target_path_no_ext_lower,
|
|
||||||
target_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
for note in notes:
|
|
||||||
if note.get('type') != 'note':
|
|
||||||
continue
|
|
||||||
|
|
||||||
source_path = note['path']
|
|
||||||
|
|
||||||
# Skip self-references
|
|
||||||
if source_path == target_path:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get source folder for resolving relative markdown links
|
|
||||||
source_folder = str(Path(source_path).parent).replace('\\', '/')
|
source_folder = str(Path(source_path).parent).replace('\\', '/')
|
||||||
if source_folder == '.':
|
if source_folder == '.':
|
||||||
source_folder = ''
|
source_folder = ''
|
||||||
|
|
||||||
# Read note content
|
|
||||||
full_path = Path(notes_dir) / source_path
|
full_path = Path(notes_dir) / source_path
|
||||||
try:
|
try:
|
||||||
with open(full_path, 'r', encoding='utf-8') as f:
|
with open(full_path, 'r', encoding='utf-8') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
return []
|
||||||
|
|
||||||
lines = content.split('\n')
|
lines = content.split('\n')
|
||||||
found_links = []
|
found_links: List[Dict] = []
|
||||||
|
|
||||||
for line_num, line in enumerate(lines, 1):
|
for line_num, line in enumerate(lines, 1):
|
||||||
# Find wikilinks: [[target]] or [[target|display]]
|
for match in re.finditer(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]', line):
|
||||||
# Wikilinks use GLOBAL matching (find note anywhere by name)
|
|
||||||
wikilink_matches = re.finditer(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]', line)
|
|
||||||
for match in wikilink_matches:
|
|
||||||
link_target = match.group(1).strip().lower()
|
link_target = match.group(1).strip().lower()
|
||||||
link_target_no_ext = link_target.replace('.md', '')
|
link_target_no_ext = link_target.replace('.md', '')
|
||||||
|
|
||||||
# Check if this wikilink points to our target (global match)
|
|
||||||
if link_target in wikilink_refs or link_target_no_ext in wikilink_refs:
|
if link_target in wikilink_refs or link_target_no_ext in wikilink_refs:
|
||||||
start = max(0, match.start() - 30)
|
start = max(0, match.start() - 30)
|
||||||
end = min(len(line), match.end() + 30)
|
end = min(len(line), match.end() + 30)
|
||||||
|
|
@ -1112,47 +1098,34 @@ def get_backlinks(notes_dir: str, target_note_path: str) -> List[Dict]:
|
||||||
context = '...' + context
|
context = '...' + context
|
||||||
if end < len(line):
|
if end < len(line):
|
||||||
context = context + '...'
|
context = context + '...'
|
||||||
|
|
||||||
found_links.append({
|
found_links.append({
|
||||||
"line_number": line_num,
|
"line_number": line_num,
|
||||||
"context": context,
|
"context": context,
|
||||||
"type": "wikilink"
|
"type": "wikilink",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Find markdown links: [text](path)
|
for match in re.finditer(r'\[([^\]]+)\]\((?!https?://|mailto:|#|data:)([^\)]+)\)', line):
|
||||||
# Markdown links must RESOLVE as paths (relative to source or absolute)
|
link_path = match.group(2).split('#')[0]
|
||||||
markdown_matches = re.finditer(r'\[([^\]]+)\]\((?!https?://|mailto:|#|data:)([^\)]+)\)', line)
|
|
||||||
for match in markdown_matches:
|
|
||||||
link_path = match.group(2).split('#')[0] # Remove anchor
|
|
||||||
if not link_path:
|
if not link_path:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
link_path = urllib.parse.unquote(link_path)
|
link_path = urllib.parse.unquote(link_path)
|
||||||
if link_path.startswith('./'):
|
if link_path.startswith('./'):
|
||||||
link_path = link_path[2:]
|
link_path = link_path[2:]
|
||||||
|
|
||||||
# Add .md if not present
|
|
||||||
link_path_with_md = link_path if link_path.endswith('.md') else link_path + '.md'
|
link_path_with_md = link_path if link_path.endswith('.md') else link_path + '.md'
|
||||||
|
|
||||||
# Resolve the link path to get the actual target
|
|
||||||
resolved_path = None
|
resolved_path = None
|
||||||
|
|
||||||
# 1. Try resolving relative to source folder
|
|
||||||
if source_folder and not link_path.startswith('/'):
|
if source_folder and not link_path.startswith('/'):
|
||||||
relative_path = f"{source_folder}/{link_path_with_md}"
|
relative_path = f"{source_folder}/{link_path_with_md}"
|
||||||
if relative_path.lower() == target_path_lower:
|
if relative_path.lower() == target_path_lower:
|
||||||
resolved_path = target_path
|
resolved_path = target_path
|
||||||
elif f"{source_folder}/{link_path}".lower() == target_path_no_ext_lower:
|
elif f"{source_folder}/{link_path}".lower() == target_path_no_ext_lower:
|
||||||
resolved_path = target_path
|
resolved_path = target_path
|
||||||
|
|
||||||
# 2. Try as absolute path from root
|
|
||||||
if not resolved_path:
|
if not resolved_path:
|
||||||
if link_path_with_md.lower() == target_path_lower:
|
if link_path_with_md.lower() == target_path_lower:
|
||||||
resolved_path = target_path
|
resolved_path = target_path
|
||||||
elif link_path.lower() == target_path_no_ext_lower:
|
elif link_path.lower() == target_path_no_ext_lower:
|
||||||
resolved_path = target_path
|
resolved_path = target_path
|
||||||
|
|
||||||
# Only add if the resolved path matches the target
|
|
||||||
if resolved_path:
|
if resolved_path:
|
||||||
start = max(0, match.start() - 30)
|
start = max(0, match.start() - 30)
|
||||||
end = min(len(line), match.end() + 30)
|
end = min(len(line), match.end() + 30)
|
||||||
|
|
@ -1161,20 +1134,49 @@ def get_backlinks(notes_dir: str, target_note_path: str) -> List[Dict]:
|
||||||
context = '...' + context
|
context = '...' + context
|
||||||
if end < len(line):
|
if end < len(line):
|
||||||
context = context + '...'
|
context = context + '...'
|
||||||
|
|
||||||
found_links.append({
|
found_links.append({
|
||||||
"line_number": line_num,
|
"line_number": line_num,
|
||||||
"context": context,
|
"context": context,
|
||||||
"type": "markdown"
|
"type": "markdown",
|
||||||
})
|
})
|
||||||
|
|
||||||
# If we found links in this note, add it to backlinks
|
return found_links
|
||||||
if found_links:
|
|
||||||
|
|
||||||
|
def get_backlinks(notes_dir: str, target_note_path: str) -> List[Dict]:
|
||||||
|
"""All notes that link TO `target_note_path`. The index narrows the
|
||||||
|
candidate set; we only read the candidate files for line context."""
|
||||||
|
target_path = target_note_path
|
||||||
|
target_path_lower = target_path.lower()
|
||||||
|
target_path_no_ext_lower = target_path_lower.replace('.md', '')
|
||||||
|
wikilink_refs = {
|
||||||
|
target_path_lower,
|
||||||
|
target_path_no_ext_lower,
|
||||||
|
Path(target_path).stem.lower(),
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
records = [(p, r) for (p, r) in records if r is not None and r.type == "note" and p != target_path]
|
||||||
|
records.sort(key=lambda pr: pr[1].mtime, reverse=True)
|
||||||
|
|
||||||
|
backlinks: List[Dict] = []
|
||||||
|
for source_path, record in records:
|
||||||
|
refs = _extract_backlink_references(
|
||||||
|
notes_dir,
|
||||||
|
source_path,
|
||||||
|
target_path,
|
||||||
|
target_path_lower,
|
||||||
|
target_path_no_ext_lower,
|
||||||
|
wikilink_refs,
|
||||||
|
)
|
||||||
|
if refs:
|
||||||
backlinks.append({
|
backlinks.append({
|
||||||
"path": source_path,
|
"path": source_path,
|
||||||
"name": note['name'].replace('.md', ''),
|
"name": record.name.replace('.md', ''),
|
||||||
"references": found_links[:3] # Limit to 3 references per note
|
"references": refs[:3],
|
||||||
})
|
})
|
||||||
|
|
||||||
return backlinks
|
return backlinks
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
478
frontend/app.js
478
frontend/app.js
|
|
@ -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,7 +1759,15 @@ function noteApp() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create note from template
|
// Optimistic stub: add empty entry so sidebar updates instantly.
|
||||||
|
// Server rendering may inject content/tags; loadNote() fetches the
|
||||||
|
// real body and tags are refreshed via loadTagsDebounced below.
|
||||||
|
this._optimisticAddNote(notePath, { content: '' });
|
||||||
|
const folderPart = notePath.includes('/') ? notePath.substring(0, notePath.lastIndexOf('/')) : '';
|
||||||
|
if (folderPart) this.expandedFolders.add(folderPart);
|
||||||
|
this._rebuildTreeAfterMutation();
|
||||||
|
|
||||||
|
try {
|
||||||
const response = await fetch('/api/templates/create-note', {
|
const response = await fetch('/api/templates/create-note', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|
@ -1747,28 +1776,26 @@ function noteApp() {
|
||||||
notePath: notePath
|
notePath: notePath
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
this.toast(error.detail || this.t('templates.create_failed'), { type: 'error' });
|
throw new Error(error.detail || this.t('templates.create_failed'));
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
this.lastUsedTemplate = this.selectedTemplate;
|
this.lastUsedTemplate = this.selectedTemplate;
|
||||||
try { localStorage.setItem('lastUsedTemplate', this.selectedTemplate); } catch (_) {}
|
try { localStorage.setItem('lastUsedTemplate', this.selectedTemplate); } catch (_) {}
|
||||||
|
|
||||||
// Close modal and reset state
|
|
||||||
this.showTemplateModal = false;
|
this.showTemplateModal = false;
|
||||||
this.selectedTemplate = '';
|
this.selectedTemplate = '';
|
||||||
this.newTemplateNoteName = '';
|
this.newTemplateNoteName = '';
|
||||||
|
|
||||||
// Reload notes and open the new note
|
|
||||||
await this.loadNotes();
|
|
||||||
await this.loadNote(data.path);
|
await this.loadNote(data.path);
|
||||||
this.focusEditorForNewNote();
|
this.focusEditorForNewNote();
|
||||||
|
this.loadTagsDebounced();
|
||||||
|
} catch (err) {
|
||||||
|
this.toast(err.message || this.t('templates.create_failed'), { type: 'error' });
|
||||||
|
await this.loadNotes({ silent: true });
|
||||||
|
}
|
||||||
} 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;
|
|
||||||
if (this.currentNote) {
|
|
||||||
await this.insertMediaMarkdown(mediaPath, file.name, cursorPos);
|
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) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
throw new Error(error.detail || 'Upload failed');
|
throw new Error(error.detail || 'Upload failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.path;
|
// Drop the new file into the local note list so the wikilink
|
||||||
} catch (error) {
|
// resolver finds it without a full /api/notes refresh.
|
||||||
throw error;
|
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,19 +4225,13 @@ 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);
|
||||||
|
|
||||||
try {
|
this._optimisticRenameFolderTree(draggedPath, newPath);
|
||||||
const response = await fetch('/api/folders/move', {
|
this._rebuildTreeAfterMutation();
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ oldPath: draggedPath, newPath })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Update favorites for notes inside moved folder
|
|
||||||
const favoritesInFolder = this.favorites.filter(f => f.startsWith(oldPrefix));
|
const favoritesInFolder = this.favorites.filter(f => f.startsWith(oldPrefix));
|
||||||
if (favoritesInFolder.length > 0) {
|
if (favoritesInFolder.length > 0) {
|
||||||
const newFavorites = this.favorites.map(f =>
|
const newFavorites = this.favorites.map(f =>
|
||||||
|
|
@ -4130,25 +4241,30 @@ function noteApp() {
|
||||||
this.favoritesSet = new Set(newFavorites);
|
this.favoritesSet = new Set(newFavorites);
|
||||||
this.saveFavorites();
|
this.saveFavorites();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep folder expanded if it was
|
|
||||||
const wasExpanded = this.expandedFolders.has(draggedPath);
|
|
||||||
|
|
||||||
await this.loadNotes();
|
|
||||||
await this.loadSharedNotePaths();
|
|
||||||
|
|
||||||
if (wasExpanded) {
|
if (wasExpanded) {
|
||||||
this.expandedFolders.delete(draggedPath);
|
this.expandedFolders.delete(draggedPath);
|
||||||
this.expandedFolders.add(newPath);
|
this.expandedFolders.add(newPath);
|
||||||
this.saveExpandedFolders();
|
this.saveExpandedFolders();
|
||||||
}
|
}
|
||||||
} else {
|
if (this.currentNote && this.currentNote.startsWith(oldPrefix)) {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
this.currentNote = newPrefix + this.currentNote.substring(oldPrefix.length);
|
||||||
this.toast(errorData.detail || this.t('move.failed_folder'), { type: 'error' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/folders/move', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ oldPath: draggedPath, newPath })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
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) {
|
|
||||||
const folderPart = notePath.includes('/') ? notePath.substring(0, notePath.lastIndexOf('/')) : '';
|
|
||||||
if (folderPart) this.expandedFolders.add(folderPart);
|
|
||||||
await this.loadNotes();
|
|
||||||
await this.loadNote(notePath);
|
await this.loadNote(notePath);
|
||||||
this.focusEditorForNewNote();
|
this.focusEditorForNewNote();
|
||||||
return true;
|
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) {
|
|
||||||
if (targetFolder) {
|
|
||||||
this.expandedFolders.add(targetFolder);
|
|
||||||
}
|
|
||||||
this.expandedFolders.add(folderPath);
|
|
||||||
await this.loadNotes();
|
|
||||||
this.goToHomepageFolder(folderPath);
|
this.goToHomepageFolder(folderPath);
|
||||||
return true;
|
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) {
|
||||||
try {
|
const folderPrefix = folderPath + '/';
|
||||||
const response = await fetch('/api/folders/rename', {
|
const newFolderPrefix = newPath + '/';
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
// Optimistic cascade: rewrite folder + every nested folder + every
|
||||||
body: JSON.stringify({
|
// note path. Sidebar reflects the rename instantly.
|
||||||
oldPath: folderPath,
|
this._optimisticRenameFolderTree(folderPath, newPath);
|
||||||
newPath: newPath
|
this._rebuildTreeAfterMutation();
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
if (this.expandedFolders.has(folderPath)) {
|
if (this.expandedFolders.has(folderPath)) {
|
||||||
this.expandedFolders.delete(folderPath);
|
this.expandedFolders.delete(folderPath);
|
||||||
this.expandedFolders.add(newPath);
|
this.expandedFolders.add(newPath);
|
||||||
}
|
}
|
||||||
|
const newFavorites = this.favorites.map(f =>
|
||||||
const folderPrefix = folderPath + '/';
|
f.startsWith(folderPrefix) ? newFolderPrefix + f.substring(folderPrefix.length) : f
|
||||||
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)) {
|
if (JSON.stringify(newFavorites) !== JSON.stringify(this.favorites)) {
|
||||||
this.favorites = newFavorites;
|
this.favorites = newFavorites;
|
||||||
this.favoritesSet = new Set(newFavorites);
|
this.favoritesSet = new Set(newFavorites);
|
||||||
this.saveFavorites();
|
this.saveFavorites();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
|
if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
|
||||||
this.currentNote = this.currentNote.replace(folderPrefix, newFolderPrefix);
|
this.currentNote = newFolderPrefix + this.currentNote.substring(folderPrefix.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.loadNotes();
|
try {
|
||||||
|
const response = await fetch('/api/folders/rename', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ oldPath: folderPath, newPath: newPath })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Server returned error');
|
||||||
return true;
|
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;
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/folders/${encodeURIComponent(folderPath)}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Remove from expanded folders
|
|
||||||
this.expandedFolders.delete(folderPath);
|
|
||||||
|
|
||||||
// Remove any favorites that were in the deleted folder
|
|
||||||
const folderPrefix = folderPath + '/';
|
const folderPrefix = folderPath + '/';
|
||||||
|
|
||||||
|
this._optimisticRemoveFolderTree(folderPath);
|
||||||
|
this._rebuildTreeAfterMutation();
|
||||||
|
|
||||||
|
this.expandedFolders.delete(folderPath);
|
||||||
const newFavorites = this.favorites.filter(f => !f.startsWith(folderPrefix));
|
const newFavorites = this.favorites.filter(f => !f.startsWith(folderPrefix));
|
||||||
if (newFavorites.length !== this.favorites.length) {
|
if (newFavorites.length !== this.favorites.length) {
|
||||||
this.favorites = newFavorites;
|
this.favorites = newFavorites;
|
||||||
this.favoritesSet = new Set(newFavorites);
|
this.favoritesSet = new Set(newFavorites);
|
||||||
this.saveFavorites();
|
this.saveFavorites();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear current note if it was in the deleted folder
|
|
||||||
if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
|
if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
|
||||||
this.currentNote = '';
|
this.currentNote = '';
|
||||||
this.noteContent = '';
|
this.noteContent = '';
|
||||||
document.title = this.appName;
|
document.title = this.appName;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.loadNotes();
|
try {
|
||||||
} else {
|
const response = await fetch(`/api/folders/${encodeURIComponent(folderPath)}`, {
|
||||||
ErrorHandler.handle('delete folder', new Error('Server returned error'));
|
method: 'DELETE',
|
||||||
}
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Server returned error');
|
||||||
|
this.loadTagsDebounced();
|
||||||
} 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) {
|
|
||||||
// Delete old note
|
|
||||||
await fetch(`/api/notes/${oldPath}`, { method: 'DELETE' });
|
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;
|
||||||
|
|
||||||
try {
|
// Optimistic: remove locally + clear current note + drop favorite
|
||||||
const response = await fetch(`/api/notes/${notePath}`, {
|
// before the network round-trip. Sidebar refreshes in <1ms on any
|
||||||
method: 'DELETE'
|
// vault size. On error we resync silently from disk.
|
||||||
});
|
this._optimisticRemoveNote(notePath);
|
||||||
|
this._rebuildTreeAfterMutation();
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Remove from favorites if it was favorited
|
|
||||||
if (this.favoritesSet.has(notePath)) {
|
if (this.favoritesSet.has(notePath)) {
|
||||||
const newFavorites = this.favorites.filter(f => f !== notePath);
|
this.favorites = this.favorites.filter(f => f !== notePath);
|
||||||
this.favorites = newFavorites;
|
this.favoritesSet = new Set(this.favorites);
|
||||||
this.favoritesSet = new Set(newFavorites);
|
|
||||||
this.saveFavorites();
|
this.saveFavorites();
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the deleted note is currently open, clear it
|
|
||||||
if (this.currentNote === notePath) {
|
if (this.currentNote === notePath) {
|
||||||
this.currentNote = '';
|
this.currentNote = '';
|
||||||
this.noteContent = '';
|
this.noteContent = '';
|
||||||
this.currentNoteName = '';
|
this.currentNoteName = '';
|
||||||
this._lastRenderedContent = ''; // Clear render cache
|
this._lastRenderedContent = '';
|
||||||
this._lastRenderedNote = '';
|
this._lastRenderedNote = '';
|
||||||
this._cachedRenderedHTML = '';
|
this._cachedRenderedHTML = '';
|
||||||
document.title = this.appName;
|
document.title = this.appName;
|
||||||
// Redirect to root
|
|
||||||
window.history.replaceState({}, '', '/');
|
window.history.replaceState({}, '', '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.loadNotes();
|
try {
|
||||||
} else {
|
const response = await fetch(`/api/notes/${notePath}`, { method: 'DELETE' });
|
||||||
ErrorHandler.handle('delete note', new Error('Server returned error'));
|
if (!response.ok) throw new Error('Server returned error');
|
||||||
}
|
this.loadTagsDebounced();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorHandler.handle('delete note', error);
|
ErrorHandler.handle('delete note', error);
|
||||||
|
await this.loadNotes({ silent: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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é",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "すべて展開",
|
||||||
|
|
|
||||||
|
|
@ -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": "Развернуть все папки",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "展开所有文件夹",
|
||||||
|
|
|
||||||
|
|
@ -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
45
run.py
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue