diff --git a/backend/main.py b/backend/main.py
index d4cb453..731f3aa 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,1891 +1,1904 @@
-"""
-NoteDiscovery - Self-Hosted Markdown Knowledge Base
-Main FastAPI application
-"""
-
-from fastapi import FastAPI, HTTPException, UploadFile, File, Request, Form, Depends, APIRouter, Response
-from fastapi.staticfiles import StaticFiles
-from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse
-from fastapi.middleware.cors import CORSMiddleware
-from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials, APIKeyHeader
-from starlette.middleware.sessions import SessionMiddleware
-import os
-import yaml
-import json
-from pathlib import Path
-from typing import List, Optional
-import aiofiles
-from datetime import datetime
-import bcrypt
-import secrets
-from slowapi import Limiter, _rate_limit_exceeded_handler
-from slowapi.util import get_remote_address
-from slowapi.errors import RateLimitExceeded
-
-from .utils import (
- scan_notes_fast_walk,
- get_note_content,
- save_note,
- delete_note,
- search_notes,
- create_note_metadata,
- ensure_directories,
- create_folder,
- move_note,
- move_folder,
- rename_folder,
- delete_folder,
- save_uploaded_image,
- validate_path_security,
- get_all_tags,
- get_notes_by_tag,
- get_templates,
- get_template_content,
- apply_template_placeholders,
- paginate,
- get_backlinks,
-)
-from .plugins import PluginManager
-from .themes import get_available_themes, get_theme_css
-from .share import (
- create_share_token,
- get_share_token,
- get_share_info,
- revoke_share_token,
- get_note_by_token,
- delete_token_for_note,
- update_token_path,
- get_all_shared_paths,
-)
-from .export import generate_export_html, embed_images_as_base64, convert_wikilinks_to_html, strip_frontmatter
-
-# Load configuration
-config_path = Path(__file__).parent.parent / "config.yaml"
-with open(config_path, 'r', encoding='utf-8') as f:
- config = yaml.safe_load(f)
-
-# Load version from VERSION file (single source of truth)
-version_path = Path(__file__).parent.parent / "VERSION"
-if not version_path.exists():
- raise FileNotFoundError("VERSION file not found. Please create it with the current version number.")
-with open(version_path, 'r', encoding='utf-8') as f:
- version = f.read().strip()
- config['app']['version'] = version
-
-# Environment variable overrides for authentication settings
-# Allows different configs for local vs production deployments
-if 'AUTHENTICATION_ENABLED' in os.environ:
- auth_enabled = os.getenv('AUTHENTICATION_ENABLED', 'false').lower() in ('true', '1', 'yes')
- config['authentication']['enabled'] = auth_enabled
- print(f"๐ Authentication {'ENABLED' if auth_enabled else 'DISABLED'} (from AUTHENTICATION_ENABLED env var)")
-else:
- print(f"๐ Authentication {'ENABLED' if config.get('authentication', {}).get('enabled', False) else 'DISABLED'} (from config.yaml)")
-
-# Password configuration priority:
-# 1. AUTHENTICATION_PASSWORD env var (hashed at startup)
-# 2. authentication.password in config.yaml (hashed at startup)
-# Default password is "admin" if nothing is configured
-if 'AUTHENTICATION_PASSWORD' in os.environ:
- plain_password = os.getenv('AUTHENTICATION_PASSWORD', '').strip()
- if plain_password:
- config['authentication']['password_hash'] = bcrypt.hashpw(
- plain_password.encode('utf-8'),
- bcrypt.gensalt()
- ).decode('utf-8')
- print("๐ Password loaded from AUTHENTICATION_PASSWORD env var")
- else:
- print("โ ๏ธ WARNING: AUTHENTICATION_PASSWORD env var is empty - ignoring")
-elif config.get('authentication', {}).get('password', '').strip():
- plain_password = config['authentication']['password'].strip()
- config['authentication']['password_hash'] = bcrypt.hashpw(
- plain_password.encode('utf-8'),
- bcrypt.gensalt()
- ).decode('utf-8')
- del config['authentication']['password']
- print("๐ Password loaded from config.yaml")
-
-# Allow secret key to be set via environment variable (for session security)
-if 'AUTHENTICATION_SECRET_KEY' in os.environ:
- config['authentication']['secret_key'] = os.getenv('AUTHENTICATION_SECRET_KEY')
- print("๐ Secret key loaded from AUTHENTICATION_SECRET_KEY env var")
-
-# API key configuration for external integrations (MCP servers, scripts, etc.)
-# Priority: AUTHENTICATION_API_KEY env var > authentication.api_key in config.yaml
-if 'AUTHENTICATION_API_KEY' in os.environ:
- api_key_value = os.getenv('AUTHENTICATION_API_KEY', '').strip()
- if api_key_value:
- config['authentication']['api_key'] = api_key_value
- print("๐ API key loaded from AUTHENTICATION_API_KEY env var")
- else:
- config['authentication']['api_key'] = ''
-elif config.get('authentication', {}).get('api_key', '').strip():
- print("๐ API key loaded from config.yaml")
-else:
- config['authentication']['api_key'] = ''
-
-# Warnings for missing authentication methods (only when auth is enabled)
-if config.get('authentication', {}).get('enabled', False):
- _has_password = bool(config.get('authentication', {}).get('password_hash', ''))
- _has_api_key = bool(config.get('authentication', {}).get('api_key', '').strip())
- _secret_key = config.get('authentication', {}).get('secret_key', '')
- _is_default_secret = _secret_key in ('', 'change_this_to_a_random_secret_key_in_production')
-
- if not _has_password and not _has_api_key:
- print("๐จ CRITICAL: Authentication enabled but NO auth methods configured - ALL access will be denied!")
- else:
- if not _has_password:
- print("โ ๏ธ WARNING: No password configured - web UI login will not work")
- if not _has_api_key:
- print("โ ๏ธ WARNING: No API key configured - external integrations will require session cookies")
-
- if _is_default_secret:
- print("๐จ SECURITY WARNING: Using default secret_key - sessions can be forged! Change it in config.yaml")
-
-# OpenAPI tag metadata for grouping endpoints in Swagger UI
-tags_metadata = [
- {"name": "Notes", "description": "Create, read, update, delete notes"},
- {"name": "Folders", "description": "Folder management"},
- {"name": "Media", "description": "Media files (images, audio, video, PDF)"},
- {"name": "Search", "description": "Full-text search"},
- {"name": "Sharing", "description": "Public note sharing via tokens"},
- {"name": "Tags", "description": "Tag-based organization"},
- {"name": "Templates", "description": "Note templates"},
- {"name": "Themes", "description": "UI theme management"},
- {"name": "Locales", "description": "Internationalization (i18n)"},
- {"name": "Graph", "description": "Note relationship graph"},
- {"name": "Plugins", "description": "Plugin management"},
- {"name": "System", "description": "Health checks and configuration"},
-]
-
-# Initialize app
-app = FastAPI(
- title=config['app']['name'],
- version=config['app']['version'],
- docs_url='/api', # Default is /docs
- redoc_url=None, # Disable ReDoc at /redoc
- openapi_tags=tags_metadata
-)
-
-# CORS middleware configuration
-# Use config.yaml to control allowed origins (default: ["*"] for self-hosted simplicity)
-allowed_origins = config.get('server', {}).get('allowed_origins', ["*"])
-app.add_middleware(
- CORSMiddleware,
- allow_origins=allowed_origins,
- allow_credentials=True,
- allow_methods=["*"],
- allow_headers=["*"],
-)
-print(f"๐ CORS allowed origins: {allowed_origins}")
-
-# ===========================================================
-# =================
-# Security Helpers
-# ============================================================================
-
-def safe_error_message(error: Exception, user_message: str = "An error occurred") -> str:
- """
- Return safe error message for API responses.
- In debug mode, returns full error details.
- In production, returns generic message and logs full details server-side.
-
- Args:
- error: The caught exception
- user_message: User-friendly message to show in production
-
- Returns:
- Safe error message string
- """
- error_details = f"{type(error).__name__}: {str(error)}"
-
- # Always log the full error server-side
- print(f"โ ๏ธ [ERROR] {error_details}")
-
- # In debug mode, return detailed error to help with development
- if config.get('server', {}).get('debug', False):
- return error_details
-
- # In production, return generic message (full details already logged)
- return user_message
-
-# Session middleware for authentication
-# Security: Session ID is regenerated after login to prevent session fixation attacks
-app.add_middleware(
- SessionMiddleware,
- secret_key=config.get('authentication', {}).get('secret_key', 'insecure_default_key_change_this'),
- max_age=config.get('authentication', {}).get('session_max_age', 604800), # 7 days default
- same_site='lax', # Prevents CSRF attacks
- https_only=False # Set to True if using HTTPS in production
-)
-
-# Demo mode - Centralizes all demo-specific restrictions
-# When DEMO_MODE=true, enables rate limiting and other demo protections
-# Add additional demo restrictions here as needed (e.g., disable certain features)
-DEMO_MODE = os.getenv('DEMO_MODE', 'false').lower() in ('true', '1', 'yes')
-ALREADY_DONATED = os.getenv('ALREADY_DONATED', 'false').lower() in ('true', '1', 'yes')
-
-if DEMO_MODE:
- # Enable rate limiting for demo deployments
- limiter = Limiter(key_func=get_remote_address, default_limits=["200/hour"])
- app.state.limiter = limiter
- app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
- print("๐ญ DEMO MODE enabled - Rate limiting active")
-else:
- # Production/self-hosted mode - no restrictions
- # Create a dummy limiter that doesn't actually limit
- class DummyLimiter:
- def limit(self, *args, **kwargs):
- def decorator(func):
- return func
- return decorator
- limiter = DummyLimiter()
-
-# Ensure required directories exist
-ensure_directories(config)
-
-# Initialize plugin manager
-plugin_manager = PluginManager(config['storage']['plugins_dir'])
-
-# Run app startup hooks
-plugin_manager.run_hook('on_app_startup')
-
-# Mount static files
-static_path = Path(__file__).parent.parent / "frontend"
-app.mount("/static", StaticFiles(directory=static_path), name="static")
-
-# PWA Service Worker - must be served from root for proper scope
-@app.get("/sw.js", include_in_schema=False)
-@limiter.limit("30/minute")
-async def service_worker(request: Request):
- """Serve the PWA service worker from root path for proper scope.
- Injects the app version from VERSION file for cache invalidation."""
- sw_path = static_path / "sw.js"
- if sw_path.exists():
- async with aiofiles.open(sw_path, 'r', encoding='utf-8') as f:
- content = await f.read()
- # Inject app version into cache name
- content = content.replace('__APP_VERSION__', version)
- return Response(content=content, media_type="application/javascript")
- raise HTTPException(status_code=404, detail="Service worker not found")
-
-
-# ============================================================================
-# Custom Exception Handlers
-# ============================================================================
-
-@app.exception_handler(HTTPException)
-async def http_exception_handler(request: Request, exc: HTTPException):
- """
- Custom exception handler for HTTP exceptions.
- Handles 401 errors specially:
- - For API requests: return JSON error
- - For page requests: redirect to login
- """
- # Only handle 401 errors specially
- if exc.status_code == 401:
- # Check if this is an API request
- if request.url.path.startswith('/api/'):
- return JSONResponse(
- status_code=401,
- content={"detail": exc.detail}
- )
-
- # For page requests, redirect to login
- return RedirectResponse(url='/login', status_code=303)
-
- # For all other HTTP exceptions, return default JSON response
- return JSONResponse(
- status_code=exc.status_code,
- content={"detail": exc.detail}
- )
-
-
-# ============================================================================
-# Authentication Helpers
-# ============================================================================
-
-# Security schemes for API authentication (auto_error=False for optional auth)
-# These are automatically added to OpenAPI docs (/api)
-bearer_scheme = HTTPBearer(auto_error=False, description="Bearer token authentication")
-api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False, description="API key header authentication")
-
-
-def auth_enabled() -> bool:
- """Check if authentication is enabled in config"""
- return config.get('authentication', {}).get('enabled', False)
-
-
-def get_api_key() -> str:
- """Get the configured API key (empty string if not set)"""
- return config.get('authentication', {}).get('api_key', '').strip()
-
-
-def verify_api_key(provided_key: str) -> bool:
- """
- Verify an API key using constant-time comparison.
-
- Uses secrets.compare_digest to prevent timing attacks where an attacker
- could determine the correct key by measuring response times.
-
- Args:
- provided_key: The API key provided in the request
-
- Returns:
- True if the key is valid, False otherwise
- """
- configured_key = get_api_key()
-
- # No API key configured = API key auth disabled
- if not configured_key:
- return False
-
- # Empty provided key is always invalid
- if not provided_key:
- return False
-
- # Constant-time comparison to prevent timing attacks
- try:
- return secrets.compare_digest(provided_key.encode('utf-8'), configured_key.encode('utf-8'))
- except Exception:
- return False
-
-
-async def require_auth(
- request: Request,
- bearer_credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
- x_api_key: Optional[str] = Depends(api_key_header)
-):
- """
- Dependency to require authentication on protected routes.
-
- Supports two authentication methods:
- 1. Session-based auth (web UI login with password)
- 2. API key auth (for external integrations like MCP servers)
-
- API key can be provided via:
- - Authorization: Bearer YOUR_API_KEY
- - X-API-Key: YOUR_API_KEY
-
- Raises:
- HTTPException: 401 if authentication fails
- """
- if not auth_enabled():
- return # Auth disabled, allow all
-
- # Method 1: Check Bearer token (parsed by FastAPI's HTTPBearer)
- if bearer_credentials and verify_api_key(bearer_credentials.credentials):
- return # Valid Bearer token - authenticated
-
- # Method 2: Check X-API-Key header (parsed by FastAPI's APIKeyHeader)
- if x_api_key and verify_api_key(x_api_key):
- return # Valid API key header - authenticated
-
- # Method 3: Check session-based authentication (web UI)
- if request.session.get('authenticated'):
- return # Valid session - authenticated
-
- # No valid authentication method - deny access
- raise HTTPException(status_code=401, detail="Not authenticated")
-
-
-def verify_password(password: str) -> bool:
- """Verify password against stored hash"""
- password_hash = config.get('authentication', {}).get('password_hash', '')
- if not password_hash:
- return False
-
- try:
- return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
- except Exception as e:
- print(f"Password verification error: {e}")
- return False
-
-
-# ============================================================================
-# Authentication Routes
-# ============================================================================
-
-@app.get("/login", response_class=HTMLResponse, include_in_schema=False)
-async def login_page(request: Request, error: str = None):
- """Serve the login page"""
- if not auth_enabled():
- return RedirectResponse(url="/", status_code=303)
-
- # If already authenticated, redirect to home
- if request.session.get('authenticated'):
- return RedirectResponse(url="/", status_code=303)
-
- # Serve login page
- login_path = static_path / "login.html"
- async with aiofiles.open(login_path, 'r', encoding='utf-8') as f:
- content = await f.read()
-
- # Inject app name throughout the login page
- app_name = config['app']['name']
- content = content.replace('NoteDiscovery', app_name)
-
- return content
-
-
-@app.post("/login", include_in_schema=False)
-async def login(request: Request, password: str = Form(...)):
- """Handle login form submission"""
- if not auth_enabled():
- return RedirectResponse(url="/", status_code=303)
-
- # Verify password
- if verify_password(password):
- # Session regeneration: Clear old session to prevent session fixation attacks
- # This forces the creation of a new session ID after successful authentication
- request.session.clear()
-
- # Set authenticated flag in the NEW session
- request.session['authenticated'] = True
- return RedirectResponse(url="/", status_code=303)
- else:
- # Redirect back to login with error code (frontend will translate)
- return RedirectResponse(url="/login?error=incorrect_password", status_code=303)
-
-
-@app.get("/logout", include_in_schema=False)
-async def logout(request: Request):
- """Log out the current user"""
- request.session.clear()
- return RedirectResponse(url="/login", status_code=303)
-
-# ============================================================================
-# Routers with Authentication
-# ============================================================================
-
-# Create API router with authentication dependency applied globally
-api_router = APIRouter(
- prefix="/api",
- dependencies=[Depends(require_auth)] # Apply auth to ALL routes in this router
-)
-
-# Create pages router with authentication dependency applied globally
-pages_router = APIRouter(
- dependencies=[Depends(require_auth)] # Apply auth to ALL routes in this router
-)
-
-
-# ============================================================================
-# Application Routes (with auth via router dependencies)
-# ============================================================================
-
-@api_router.get("/config", tags=["System"])
-async def get_config():
- """Get app configuration for frontend"""
- return {
- "name": config['app']['name'],
- "version": config['app']['version'],
- "searchEnabled": config['search']['enabled'],
- "demoMode": DEMO_MODE, # Expose demo mode flag to frontend
- "alreadyDonated": ALREADY_DONATED, # Hide support buttons if true
- "authentication": {
- "enabled": config.get('authentication', {}).get('enabled', False)
- }
- }
-
-
-@api_router.get("/themes", tags=["Themes"])
-async def list_themes():
- """Get all available themes"""
- themes_dir = Path(__file__).parent.parent / "themes"
- themes = get_available_themes(str(themes_dir))
- return {"themes": themes}
-
-
-@app.get("/api/themes/{theme_id}", tags=["Themes"]) # Don't use the router here, as we want this route unsecured
-async def get_theme(theme_id: str):
- """Get CSS for a specific theme"""
- themes_dir = Path(__file__).parent.parent / "themes"
- css = get_theme_css(str(themes_dir), theme_id)
-
- if not css:
- raise HTTPException(status_code=404, detail="Theme not found")
-
- return {"css": css, "theme_id": theme_id}
-
-
-# Locales endpoints (unauthenticated - needed for login page and initial load)
-@app.get("/api/locales", tags=["Locales"])
-async def get_available_locales():
- """Get list of available locales"""
- import json
- locales_dir = Path(__file__).parent.parent / "locales"
- locales = []
-
- if locales_dir.exists():
- for file in sorted(locales_dir.glob("*.json")):
- try:
- with open(file, 'r', encoding='utf-8') as f:
- data = json.load(f)
- meta = data.get('_meta', {})
- locales.append({
- "code": meta.get('code', file.stem),
- "name": meta.get('name', file.stem),
- "flag": meta.get('flag', '๐')
- })
- except (json.JSONDecodeError, IOError):
- # Skip invalid locale files
- continue
-
- return {"locales": locales}
-
-
-@app.get("/api/locales/{locale_code}", tags=["Locales"])
-async def get_locale(locale_code: str):
- """Get translations for a specific locale"""
- import json
- import re
-
- # Security: Validate locale_code to prevent path traversal
- # Only allow alphanumeric, hyphens, and underscores (e.g., "en", "pt-BR", "zh_CN")
- if not re.match(r'^[a-zA-Z0-9_-]+$', locale_code):
- raise HTTPException(status_code=400, detail="Invalid locale code")
-
- locales_dir = Path(__file__).parent.parent / "locales"
- locale_file = locales_dir / f"{locale_code}.json"
-
- # Security: Ensure resolved path is still within locales directory
- if not locale_file.resolve().is_relative_to(locales_dir.resolve()):
- raise HTTPException(status_code=400, detail="Invalid locale code")
-
- if not locale_file.exists():
- raise HTTPException(status_code=404, detail="Locale not found")
-
- try:
- with open(locale_file, 'r', encoding='utf-8') as f:
- translations = json.load(f)
- return translations
- except (json.JSONDecodeError, IOError) as e:
- raise HTTPException(status_code=500, detail=f"Failed to load locale: {str(e)}")
-
-
-@api_router.post("/folders", tags=["Folders"])
-@limiter.limit("30/minute")
-async def create_new_folder(request: Request, data: dict):
- """Create a new folder"""
- try:
- folder_path = data.get('path', '')
- if not folder_path:
- raise HTTPException(status_code=400, detail="Folder path required")
-
- success = create_folder(config['storage']['notes_dir'], folder_path)
-
- if not success:
- raise HTTPException(status_code=500, detail="Failed to create folder")
-
- return {
- "success": True,
- "path": folder_path,
- "message": "Folder created successfully"
- }
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to create folder"))
-
-
-@api_router.get("/media/{media_path:path}", tags=["Media"])
-async def get_media(media_path: str):
- """
- Serve a media file (image, audio, video, PDF) with authentication protection.
- """
- try:
- from backend.utils import ALL_MEDIA_EXTENSIONS
-
- notes_dir = config['storage']['notes_dir']
- full_path = Path(notes_dir) / media_path
-
- # Security: Validate path is within notes directory
- if not validate_path_security(notes_dir, full_path):
- raise HTTPException(status_code=403, detail="Access denied")
-
- # Check file exists
- if not full_path.exists() or not full_path.is_file():
- raise HTTPException(status_code=404, detail="File not found")
-
- # Validate it's an allowed media file
- if full_path.suffix.lower() not in ALL_MEDIA_EXTENSIONS:
- raise HTTPException(status_code=400, detail="Not an allowed media file")
-
- # Return the file
- return FileResponse(full_path)
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load media file"))
-
-
-@api_router.post("/upload-media", tags=["Media"])
-@limiter.limit("20/minute")
-async def upload_media(request: Request, file: UploadFile = File(...), note_path: str = Form(...)):
- """
- Upload a media file (image, audio, video, PDF) and save it to the attachments directory.
- Returns the relative path for markdown linking.
- """
- try:
- from backend.utils import ALL_MEDIA_EXTENSIONS, get_media_type
-
- # Allowed MIME types for each category
- allowed_types = {
- # Images
- 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
- # Audio
- 'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/x-m4a',
- # Video
- 'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo',
- # Documents
- 'application/pdf',
- }
-
- # Get file extension
- file_ext = Path(file.filename).suffix.lower() if file.filename else ''
-
- if file.content_type not in allowed_types and file_ext not in ALL_MEDIA_EXTENSIONS:
- raise HTTPException(
- status_code=400,
- detail=f"Invalid file type. Allowed: images, audio (mp3), video (mp4), PDF. Got: {file.content_type}"
- )
-
- # Read file data
- file_data = await file.read()
-
- # Validate file size - different limits for different types
- media_type = get_media_type(file.filename) if file.filename else None
-
- # Size limits: images 10MB, audio 50MB, video 100MB, PDF 20MB
- size_limits = {
- 'image': 10 * 1024 * 1024,
- 'audio': 50 * 1024 * 1024,
- 'video': 100 * 1024 * 1024,
- 'document': 20 * 1024 * 1024,
- }
- max_size = size_limits.get(media_type, 10 * 1024 * 1024)
-
- if len(file_data) > max_size:
- raise HTTPException(
- status_code=400,
- detail=f"File too large. Maximum size for {media_type or 'this type'}: {max_size // (1024*1024)}MB. Uploaded: {len(file_data) / 1024 / 1024:.2f}MB"
- )
-
- # Save the file (reusing image save function - it works for any file)
- file_path = save_uploaded_image(
- config['storage']['notes_dir'],
- note_path,
- file.filename,
- file_data
- )
-
- if not file_path:
- raise HTTPException(status_code=500, detail="Failed to save file")
-
- return {
- "success": True,
- "path": file_path,
- "filename": Path(file_path).name,
- "type": media_type,
- "message": f"{media_type.capitalize() if media_type else 'File'} uploaded successfully"
- }
-
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to upload file"))
-
-
-@api_router.post("/media/move", tags=["Media"])
-@limiter.limit("30/minute")
-async def move_media_endpoint(request: Request, data: dict):
- """Move a media file to a different folder"""
- try:
- from backend.utils import ALL_MEDIA_EXTENSIONS
-
- old_path = data.get('oldPath', '')
- new_path = data.get('newPath', '')
-
- if not old_path or not new_path:
- raise HTTPException(status_code=400, detail="Both oldPath and newPath required")
-
- notes_dir = config['storage']['notes_dir']
- old_full_path = Path(notes_dir) / old_path
- new_full_path = Path(notes_dir) / new_path
-
- # Security: Validate paths are within notes directory
- if not validate_path_security(notes_dir, old_full_path):
- raise HTTPException(status_code=403, detail="Invalid source path")
- if not validate_path_security(notes_dir, new_full_path):
- raise HTTPException(status_code=403, detail="Invalid destination path")
-
- # Validate it's a media file
- if old_full_path.suffix.lower() not in ALL_MEDIA_EXTENSIONS:
- raise HTTPException(status_code=400, detail="Not a valid media file")
-
- # Check source exists
- if not old_full_path.exists():
- raise HTTPException(status_code=404, detail=f"Media file not found: {old_path}")
-
- # Check target doesn't exist
- if new_full_path.exists():
- raise HTTPException(status_code=409, detail=f"A file already exists at: {new_path}")
-
- # Create parent directory if needed
- new_full_path.parent.mkdir(parents=True, exist_ok=True)
-
- # Move the file
- import shutil
- shutil.move(str(old_full_path), str(new_full_path))
-
- return {"success": True, "message": "Media moved successfully", "newPath": new_path}
-
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move media file"))
-
-
-@api_router.post("/notes/move", tags=["Notes"])
-@limiter.limit("30/minute")
-async def move_note_endpoint(request: Request, data: dict):
- """Move a note to a different folder"""
- try:
- old_path = data.get('oldPath', '')
- new_path = data.get('newPath', '')
-
- if not old_path or not new_path:
- raise HTTPException(status_code=400, detail="Both oldPath and newPath required")
-
- success, error_msg = move_note(config['storage']['notes_dir'], old_path, new_path)
-
- if not success:
- raise HTTPException(status_code=400, detail=error_msg or "Failed to move note")
-
- # Update share token path if note was shared
- update_token_path(config['storage']['notes_dir'], old_path, new_path)
-
- # Run plugin hooks
- plugin_manager.run_hook('on_note_save', note_path=new_path, content='')
-
- return {
- "success": True,
- "oldPath": old_path,
- "newPath": new_path,
- "message": "Note moved successfully"
- }
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move note"))
-
-
-@api_router.post("/folders/move", tags=["Folders"])
-@limiter.limit("20/minute")
-async def move_folder_endpoint(request: Request, data: dict):
- """Move a folder to a different location"""
- try:
- old_path = data.get('oldPath', '')
- new_path = data.get('newPath', '')
-
- if not old_path or not new_path:
- raise HTTPException(status_code=400, detail="Both oldPath and newPath required")
-
- success, error_msg = move_folder(config['storage']['notes_dir'], old_path, new_path)
-
- if not success:
- raise HTTPException(status_code=400, detail=error_msg or "Failed to move folder")
-
- return {
- "success": True,
- "oldPath": old_path,
- "newPath": new_path,
- "message": "Folder moved successfully"
- }
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move folder"))
-
-
-@api_router.post("/folders/rename", tags=["Folders"])
-@limiter.limit("30/minute")
-async def rename_folder_endpoint(request: Request, data: dict):
- """Rename a folder"""
- try:
- old_path = data.get('oldPath', '')
- new_path = data.get('newPath', '')
-
- if not old_path or not new_path:
- raise HTTPException(status_code=400, detail="Both oldPath and newPath required")
-
- success, error_msg = rename_folder(config['storage']['notes_dir'], old_path, new_path)
-
- if not success:
- raise HTTPException(status_code=400, detail=error_msg or "Failed to rename folder")
-
- return {
- "success": True,
- "oldPath": old_path,
- "newPath": new_path,
- "message": "Folder renamed successfully"
- }
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to rename folder"))
-
-
-@api_router.delete("/folders/{folder_path:path}", tags=["Folders"])
-@limiter.limit("20/minute")
-async def delete_folder_endpoint(request: Request, folder_path: str):
- """Delete a folder and all its contents"""
- try:
- if not folder_path:
- raise HTTPException(status_code=400, detail="Folder path required")
-
- success = delete_folder(config['storage']['notes_dir'], folder_path)
-
- if not success:
- raise HTTPException(status_code=500, detail="Failed to delete folder")
-
- return {
- "success": True,
- "path": folder_path,
- "message": "Folder deleted successfully"
- }
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to delete folder"))
-
-
-# --- Tags Endpoints ---
-
-@api_router.get("/tags", tags=["Tags"])
-async def list_tags():
- """
- Get all tags used across all notes with their counts.
-
- Returns:
- Dictionary mapping tag names to note counts
- """
- try:
- tags = get_all_tags(config['storage']['notes_dir'])
- return {"tags": tags}
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load tags"))
-
-
-@api_router.get("/tags/{tag_name}", tags=["Tags"])
-async def get_notes_by_tag_endpoint(
- tag_name: str,
- limit: Optional[int] = None,
- offset: int = 0
-):
- """
- Get all notes that have a specific tag with optional pagination.
-
- Args:
- tag_name: The tag to filter by (case-insensitive)
- limit: Maximum number of notes to return (optional, no default limit)
- offset: Number of notes to skip (default: 0)
-
- Returns:
- List of notes matching the tag
-
- Examples:
- GET /api/tags/docker -> All notes with #docker tag
- GET /api/tags/docker?limit=10 -> First 10 notes with #docker tag
- """
- try:
- notes = get_notes_by_tag(config['storage']['notes_dir'], tag_name)
-
- # Apply pagination with consistent sorting by path
- paginated = paginate(
- items=notes,
- limit=limit,
- offset=offset,
- sort_key=lambda x: x.get('path', '').lower()
- )
-
- response = {
- "tag": tag_name,
- "count": paginated.total,
- "notes": paginated.items
- }
-
- # Include pagination metadata only when limit is specified
- if limit is not None:
- response["pagination"] = paginated.to_dict()
-
- return response
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get notes by tag"))
-
-
-# --- Template Endpoints ---
-
-@api_router.get("/templates", tags=["Templates"])
-@limiter.limit("120/minute")
-async def list_templates(request: Request):
- """
- List all available templates from _templates folder.
-
- Returns:
- List of template metadata
- """
- try:
- templates = get_templates(config['storage']['notes_dir'])
- return {"templates": templates}
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to list templates"))
-
-
-@api_router.get("/templates/{template_name}", tags=["Templates"])
-@limiter.limit("120/minute")
-async def get_template(request: Request, template_name: str):
- """
- Get content of a specific template.
-
- Args:
- template_name: Name of the template (without .md extension)
-
- Returns:
- Template name and content
- """
- try:
- content = get_template_content(config['storage']['notes_dir'], template_name)
-
- if content is None:
- raise HTTPException(status_code=404, detail="Template not found")
-
- return {
- "name": template_name,
- "content": content
- }
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get template"))
-
-
-@api_router.post("/templates/create-note", tags=["Templates"])
-@limiter.limit("60/minute")
-async def create_note_from_template(request: Request, data: dict):
- """
- Create a new note from a template with placeholder replacement.
-
- Args:
- data: Dictionary containing templateName and notePath
-
- Returns:
- Success status, path, and created content
- """
- try:
- template_name = data.get('templateName', '')
- note_path = data.get('notePath', '')
-
- if not template_name or not note_path:
- raise HTTPException(status_code=400, detail="Template name and note path required")
-
- # Get template content
- template_content = get_template_content(config['storage']['notes_dir'], template_name)
-
- if template_content is None:
- raise HTTPException(status_code=404, detail="Template not found")
-
- # Apply placeholder replacements
- final_content = apply_template_placeholders(template_content, note_path)
-
- # Run on_note_create hook BEFORE saving (allows plugins to modify initial content)
- final_content = plugin_manager.run_hook_with_return(
- 'on_note_create',
- note_path=note_path,
- initial_content=final_content
- )
-
- # Run on_note_save hook (can transform content, e.g., encrypt)
- transformed_content = plugin_manager.run_hook('on_note_save', note_path=note_path, content=final_content)
- if transformed_content is None:
- transformed_content = final_content
-
- # Save the note with the (potentially modified/transformed) content
- success = save_note(config['storage']['notes_dir'], note_path, transformed_content)
-
- if not success:
- raise HTTPException(status_code=500, detail="Failed to create note from template")
-
- return {
- "success": True,
- "path": note_path,
- "message": "Note created from template successfully",
- "content": final_content
- }
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to create note from template"))
-
-
-# --- Notes Endpoints ---
-
-@api_router.get("/notes", tags=["Notes"])
-async def list_notes(
- limit: Optional[int] = None,
- offset: int = 0
-):
- """
- List all notes with metadata.
-
- Supports optional pagination for API consumers (MCP, scripts):
- - No parameters: Returns all notes (frontend compatibility)
- - With limit: Returns paginated results with metadata
-
- Args:
- limit: Maximum number of notes to return (optional, no default limit)
- offset: Number of notes to skip (default: 0)
-
- Examples:
- GET /api/notes -> All notes
- GET /api/notes?limit=20 -> First 20 notes
- GET /api/notes?limit=20&offset=20 -> Notes 21-40
- """
- try:
- notes, folders = scan_notes_fast_walk(config['storage']['notes_dir'], include_media=True)
-
- # Apply pagination with consistent sorting by path for stable results
- result = paginate(
- items=notes,
- limit=limit,
- offset=offset,
- sort_key=lambda x: x.get('path', '').lower()
- )
-
- response = {
- "notes": result.items,
- "folders": folders
- }
-
- # Include pagination metadata only when limit is specified
- if limit is not None:
- response["pagination"] = result.to_dict()
-
- return response
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to list notes"))
-
-
-@api_router.get("/notes/{note_path:path}", tags=["Notes"])
-async def get_note(note_path: str, include_backlinks: bool = True):
- """Get a specific note's content with optional backlinks"""
- try:
- content = get_note_content(config['storage']['notes_dir'], note_path)
- if content is None:
- raise HTTPException(status_code=404, detail="Note not found")
-
- # Run on_note_load hook (can transform content, e.g., decrypt)
- transformed_content = plugin_manager.run_hook('on_note_load', note_path=note_path, content=content)
- if transformed_content is not None:
- content = transformed_content
-
- response = {
- "path": note_path,
- "content": content,
- "metadata": create_note_metadata(config['storage']['notes_dir'], note_path)
- }
-
- if include_backlinks:
- response["backlinks"] = get_backlinks(config['storage']['notes_dir'], note_path)
-
- return response
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load note"))
-
-
-@api_router.post("/notes/{note_path:path}", tags=["Notes"])
-@limiter.limit("60/minute")
-async def create_or_update_note(request: Request, note_path: str, content: dict):
- """Create or update a note"""
- try:
- note_content = content.get('content', '')
-
- # Check if this is a new note (doesn't exist yet)
- existing_content = get_note_content(config['storage']['notes_dir'], note_path)
- is_new_note = existing_content is None
-
- # If creating a new note, run on_note_create hook to allow plugins to modify initial content
- if is_new_note:
- note_content = plugin_manager.run_hook_with_return(
- 'on_note_create',
- note_path=note_path,
- initial_content=note_content
- )
-
- # Run on_note_save hook (can transform content, e.g., encrypt)
- transformed_content = plugin_manager.run_hook('on_note_save', note_path=note_path, content=note_content)
- if transformed_content is None:
- transformed_content = note_content
-
- success = save_note(config['storage']['notes_dir'], note_path, transformed_content)
-
- if not success:
- raise HTTPException(status_code=500, detail="Failed to save note")
-
- return {
- "success": True,
- "path": note_path,
- "message": "Note created successfully" if is_new_note else "Note saved successfully",
- "content": note_content # Return the (potentially modified) content
- }
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to save note"))
-
-
-@api_router.patch("/notes/{note_path:path}", tags=["Notes"])
-@limiter.limit("60/minute")
-async def append_to_note(request: Request, note_path: str, data: dict):
- """
- Append content to an existing note without overwriting.
-
- Perfect for journals, logs, or collecting ideas incrementally.
-
- Args:
- note_path: Path to the note
- data: Dictionary with 'content' to append and optional 'add_timestamp' boolean
- """
- try:
- content_to_append = data.get('content', '')
- add_timestamp = data.get('add_timestamp', False)
-
- if not content_to_append:
- raise HTTPException(status_code=400, detail="Content to append is required")
-
- # Get existing content
- existing_content = get_note_content(config['storage']['notes_dir'], note_path)
-
- if existing_content is None:
- raise HTTPException(status_code=404, detail="Note not found")
-
- # Build the appended content
- if add_timestamp:
- from datetime import datetime
- timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
- content_to_append = f"\n\n---\n\n**{timestamp}**\n\n{content_to_append}"
- else:
- content_to_append = f"\n\n{content_to_append}"
-
- new_content = existing_content + content_to_append
-
- # Run on_note_save hook
- transformed_content = plugin_manager.run_hook('on_note_save', note_path=note_path, content=new_content)
- if transformed_content is None:
- transformed_content = new_content
-
- success = save_note(config['storage']['notes_dir'], note_path, transformed_content)
-
- if not success:
- raise HTTPException(status_code=500, detail="Failed to append to note")
-
- return {
- "success": True,
- "path": note_path,
- "message": "Content appended successfully"
- }
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to append to note"))
-
-
-@api_router.delete("/notes/{note_path:path}", tags=["Notes"])
-@limiter.limit("30/minute")
-async def remove_note(request: Request, note_path: str):
- """Delete a note"""
- try:
- success = delete_note(config['storage']['notes_dir'], note_path)
-
- if not success:
- raise HTTPException(status_code=404, detail="Note not found")
-
- # Clean up any share token for this note
- delete_token_for_note(config['storage']['notes_dir'], note_path)
-
- # Run plugin hooks
- plugin_manager.run_hook('on_note_delete', note_path=note_path)
-
- return {
- "success": True,
- "message": "Note deleted successfully"
- }
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to delete note"))
-
-
-@api_router.get("/export/{note_path:path}", tags=["Export"])
-@limiter.limit("30/minute")
-async def export_note_to_html(request: Request, note_path: str, theme: Optional[str] = None, download: bool = True):
- """
- Export a note as a standalone HTML file.
-
- The HTML includes all necessary CSS, MathJax, Mermaid, and syntax highlighting
- for offline viewing. Images are embedded as base64.
-
- Query Parameters:
- theme: Optional theme name (defaults to current theme or 'light')
- download: If true (default), returns as file download. If false, displays in browser with print button.
-
- Returns:
- HTML file (download or inline based on download parameter)
- """
- try:
- notes_dir = Path(config['storage']['notes_dir'])
-
- # Read note content
- content = get_note_content(str(notes_dir), note_path)
- if content is None:
- raise HTTPException(status_code=404, detail="Note not found")
-
- # Run on_note_load hook (can transform content, e.g., decrypt)
- transformed_content = plugin_manager.run_hook('on_note_load', note_path=note_path, content=content)
- if transformed_content is not None:
- content = transformed_content
-
- # Strip YAML frontmatter (like the preview does)
- content = strip_frontmatter(content)
-
- # Get note folder for resolving relative image paths
- note_file_path = notes_dir / note_path
- note_folder = note_file_path.parent
-
- # Embed images as base64
- content_with_images = embed_images_as_base64(content, note_folder, notes_dir)
-
- # Convert wikilinks to decorative HTML links
- content_with_links = convert_wikilinks_to_html(content_with_images)
-
- # Get theme CSS
- themes_dir = Path(__file__).parent.parent / "themes"
- theme_name = theme or 'light'
- theme_css = get_theme_css(str(themes_dir), theme_name)
- if not theme_css:
- theme_css = get_theme_css(str(themes_dir), "light")
- theme_name = "light"
-
- # Strip data-theme selector
- theme_css = theme_css.replace(f':root[data-theme="{theme_name}"]', ':root')
- theme_css = theme_css.replace(':root[data-theme="light"]', ':root')
- theme_css = theme_css.replace(':root[data-theme="dark"]', ':root')
-
- # Determine if dark theme
- is_dark = 'dark' in theme_name.lower() or theme_name in ['dracula', 'nord', 'monokai', 'cobalt2', 'gruvbox-dark']
-
- # Get note title
- title = Path(note_path).stem
-
- # Generate HTML (show print button only when not downloading)
- html_content = generate_export_html(
- title=title,
- content=content_with_links,
- theme_css=theme_css,
- is_dark=is_dark,
- show_print_button=not download
- )
-
- # Return as downloadable file or inline (for print preview)
- if download:
- filename = f"{title}.html"
- return Response(
- content=html_content,
- media_type="text/html",
- headers={
- "Content-Disposition": f'attachment; filename="{filename}"'
- }
- )
- else:
- # Return inline for browser display (print preview)
- return HTMLResponse(content=html_content)
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to export note"))
-
-
-@api_router.get("/search", tags=["Search"])
-async def search(
- q: str,
- limit: Optional[int] = None,
- offset: int = 0
-):
- """
- Search notes by content with optional pagination.
-
- Args:
- q: Search query string
- limit: Maximum number of results to return (optional, no default limit)
- offset: Number of results to skip (default: 0)
-
- Examples:
- GET /api/search?q=docker -> All matching results
- GET /api/search?q=docker&limit=10 -> First 10 results
- GET /api/search?q=docker&limit=10&offset=10 -> Results 11-20
- """
- try:
- if not config['search']['enabled']:
- raise HTTPException(status_code=403, detail="Search is disabled")
-
- # Handle empty query gracefully
- if not q or not q.strip():
- return {
- "results": [],
- "query": q,
- "message": "No search term provided"
- }
-
- results = search_notes(config['storage']['notes_dir'], q)
-
- # Run plugin hooks
- plugin_manager.run_hook('on_search', query=q, results=results)
-
- # Apply pagination with consistent sorting by path
- paginated = paginate(
- items=results,
- limit=limit,
- offset=offset,
- sort_key=lambda x: x.get('path', '').lower()
- )
-
- response = {
- "results": paginated.items,
- "query": q
- }
-
- # Include pagination metadata only when limit is specified
- if limit is not None:
- response["pagination"] = paginated.to_dict()
-
- return response
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Search failed"))
-
-
-@api_router.get("/graph", tags=["Graph"])
-async def get_graph():
- """Get graph data for note visualization with wikilink and markdown link detection"""
- try:
- import re
- import urllib.parse
- notes, _folders = scan_notes_fast_walk(config['storage']['notes_dir'], include_media=False)
- nodes = []
- edges = []
-
- # 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:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to generate graph data"))
-
-
-@api_router.get("/plugins", tags=["Plugins"])
-async def list_plugins():
- """List all available plugins"""
- return {"plugins": plugin_manager.list_plugins()}
-
-
-@api_router.get("/plugins/note_stats/calculate", tags=["Plugins"])
-async def calculate_note_stats(content: str):
- """Calculate statistics for note content (if plugin enabled)"""
- try:
- plugin = plugin_manager.plugins.get('note_stats')
- if not plugin or not plugin.enabled:
- return {"enabled": False, "stats": None}
-
- stats = plugin.calculate_stats(content)
- return {"enabled": True, "stats": stats}
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to calculate note statistics"))
-
-
-@api_router.post("/plugins/{plugin_name}/toggle", tags=["Plugins"])
-@limiter.limit("10/minute")
-async def toggle_plugin(request: Request, plugin_name: str, enabled: dict):
- """Enable or disable a plugin"""
- try:
- is_enabled = enabled.get('enabled', False)
- if is_enabled:
- plugin_manager.enable_plugin(plugin_name)
- else:
- plugin_manager.disable_plugin(plugin_name)
-
- return {
- "success": True,
- "plugin": plugin_name,
- "enabled": is_enabled
- }
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to toggle plugin"))
-
-
-# ============================================================================
-# Stats Endpoint (for dashboards)
-# ============================================================================
-
-@api_router.get("/stats", tags=["Stats"])
-@limiter.limit("30/minute")
-async def get_stats(request: Request):
- """
- Get application statistics at a glance.
-
- Designed for dashboard widgets (e.g., Homepage) - lightweight and cached.
- Returns counts of notes, folders, tags, templates, media, and other metadata.
- """
- try:
- notes_dir = config['storage']['notes_dir']
-
- # Get notes and folders (cached)
- notes, folders = scan_notes_fast_walk(notes_dir, include_media=True)
-
- # Separate notes from media
- note_items = [n for n in notes if n.get('type') == 'note']
- media_items = [n for n in notes if n.get('type') != 'note']
-
- # Count unique tags
- all_tags = set()
- for note in note_items:
- all_tags.update(note.get('tags', []))
-
- # Get templates count
- templates = get_templates(notes_dir)
-
- # Calculate total size
- total_size = sum(n.get('size', 0) for n in notes)
-
- # Get last modified (notes are already sorted by modified desc)
- last_modified = note_items[0].get('modified') if note_items else None
-
- # Count enabled plugins
- enabled_plugins = sum(1 for p in plugin_manager.plugins.values() if p.enabled)
-
- # Read version
- version = "unknown"
- version_file = Path(__file__).parent.parent / "VERSION"
- if version_file.exists():
- version = version_file.read_text().strip()
-
- return {
- "notes_count": len(note_items),
- "folders_count": len(folders),
- "tags_count": len(all_tags),
- "templates_count": len(templates),
- "media_count": len(media_items),
- "total_size_bytes": total_size,
- "last_modified": last_modified,
- "plugins_enabled": enabled_plugins,
- "version": version
- }
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get stats"))
-
-
-# ============================================================================
-# Share Token Endpoints (authenticated)
-# ============================================================================
-
-@api_router.post("/share/{note_path:path}", tags=["Sharing"])
-@limiter.limit("30/minute")
-async def create_share(request: Request, note_path: str, data: dict = None):
- """
- Create a share token for a note.
- Returns the share URL that can be accessed without authentication.
- Optionally accepts { "theme": "theme-name" } to set the display theme.
- """
- try:
- notes_dir = config['storage']['notes_dir']
-
- # Get theme from request body (default to light)
- theme = "light"
- if data and isinstance(data, dict):
- theme = data.get('theme', 'light')
-
- # Add .md extension if not present
- if not note_path.endswith('.md'):
- note_path = f"{note_path}.md"
-
- # Check if note exists
- content = get_note_content(notes_dir, note_path)
- if content is None:
- raise HTTPException(status_code=404, detail="Note not found")
-
- # Create or get existing token (with theme)
- token = create_share_token(notes_dir, note_path, theme)
- if not token:
- raise HTTPException(status_code=500, detail="Failed to create share token")
-
- # Build share URL
- base_url = str(request.base_url).rstrip('/')
- share_url = f"{base_url}/share/{token}"
-
- return {
- "success": True,
- "token": token,
- "url": share_url,
- "path": note_path,
- "theme": theme
- }
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to create share"))
-
-
-@api_router.get("/share/{note_path:path}", tags=["Sharing"])
-@limiter.limit("120/minute")
-async def get_share_status(request: Request, note_path: str):
- """
- Get the share status for a note.
- Returns whether the note is shared and its share URL if so.
- """
- try:
- notes_dir = config['storage']['notes_dir']
-
- # Add .md extension if not present
- if not note_path.endswith('.md'):
- note_path = f"{note_path}.md"
-
- # Get share info
- info = get_share_info(notes_dir, note_path)
-
- if info.get('shared'):
- base_url = str(request.base_url).rstrip('/')
- info['url'] = f"{base_url}/share/{info['token']}"
-
- return info
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get share status"))
-
-
-@api_router.get("/shared-notes", tags=["Sharing"])
-@limiter.limit("60/minute")
-async def list_shared_notes(request: Request):
- """
- Get a list of all currently shared note paths.
- Used for displaying share indicators in the UI.
- """
- try:
- notes_dir = config['storage']['notes_dir']
- shared_paths = get_all_shared_paths(notes_dir)
- return {"paths": shared_paths}
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get shared notes"))
-
-
-@api_router.delete("/share/{note_path:path}", tags=["Sharing"])
-@limiter.limit("30/minute")
-async def delete_share(request: Request, note_path: str):
- """
- Revoke sharing for a note (delete the share token).
- """
- try:
- notes_dir = config['storage']['notes_dir']
-
- # Add .md extension if not present
- if not note_path.endswith('.md'):
- note_path = f"{note_path}.md"
-
- # Revoke token
- success = revoke_share_token(notes_dir, note_path)
-
- return {
- "success": success,
- "message": "Share revoked" if success else "Note was not shared"
- }
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to revoke share"))
-
-
-# ============================================================================
-# Public Share Endpoint (no authentication required)
-# ============================================================================
-
-@app.get("/share/{token}", response_class=HTMLResponse, tags=["Sharing"])
-@limiter.limit("60/minute")
-async def view_shared_note(request: Request, token: str):
- """
- View a shared note by its token.
- No authentication required - anyone with the token can view.
- """
- try:
- notes_dir = Path(config['storage']['notes_dir'])
-
- # Look up note by token (returns dict with path and theme)
- share_info = get_note_by_token(str(notes_dir), token)
- if not share_info:
- raise HTTPException(status_code=404, detail="Shared note not found or link expired")
-
- note_path = share_info['path']
- theme = share_info.get('theme', 'light')
-
- # Read note content
- content = get_note_content(str(notes_dir), note_path)
- if content is None:
- # Note was deleted but token still exists - clean up
- delete_token_for_note(str(notes_dir), note_path)
- raise HTTPException(status_code=404, detail="Note no longer exists")
-
- # Strip YAML frontmatter (like the preview does)
- content = strip_frontmatter(content)
-
- # Get note folder for resolving relative image paths
- note_file_path = notes_dir / note_path
- note_folder = note_file_path.parent
-
- # Embed images as base64
- content_with_images = embed_images_as_base64(content, note_folder, notes_dir)
-
- # Convert wikilinks to decorative HTML links
- content_with_links = convert_wikilinks_to_html(content_with_images)
-
- # Use the theme that was set when sharing
- themes_dir = Path(__file__).parent.parent / "themes"
- theme_css = get_theme_css(str(themes_dir), theme)
- if not theme_css:
- theme_css = get_theme_css(str(themes_dir), "light")
- theme = "light"
-
- # Strip data-theme selector
- theme_css = theme_css.replace(f':root[data-theme="{theme}"]', ':root')
- theme_css = theme_css.replace(':root[data-theme="light"]', ':root')
- theme_css = theme_css.replace(':root[data-theme="dark"]', ':root')
-
- # Determine if dark theme
- is_dark = 'dark' in theme.lower() or theme in ['dracula', 'nord', 'monokai', 'cobalt2', 'gruvbox-dark']
-
- # Get note title
- title = Path(note_path).stem
-
- # Generate HTML
- html_content = generate_export_html(
- title=title,
- content=content_with_links,
- theme_css=theme_css,
- is_dark=is_dark
- )
-
- return HTMLResponse(content=html_content)
- except HTTPException:
- raise
- except Exception as e:
- raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load shared note"))
-
-
-@app.get("/health", tags=["System"])
-async def health_check():
- """Health check endpoint"""
- return {
- "status": "healthy",
- "app": config['app']['name'],
- "version": config['app']['version']
- }
-
-
-# Catch-all route for SPA (Single Page Application) routing
-# This allows URLs like /folder/note to work for direct navigation
-@pages_router.get("/{full_path:path}", response_class=HTMLResponse)
-@limiter.limit("120/minute")
-async def catch_all(full_path: str, request: Request):
- """
- Serve index.html for all non-API routes (including root /).
- This enables client-side routing (e.g., /folder/note)
- """
- # Skip if it's an API route or static file (shouldn't reach here, but just in case)
- if full_path.startswith('api/') or full_path.startswith('static/'):
- raise HTTPException(status_code=404, detail="Not found")
-
- # Serve index.html with app name injected
- index_path = static_path / "index.html"
- async with aiofiles.open(index_path, 'r', encoding='utf-8') as f:
- content = await f.read()
- app_name = config['app']['name']
- return content.replace('
NoteDiscovery', f'{app_name}')
-
-
-# ============================================================================
-# Register Routers
-# ============================================================================
-
-# Register routers with the main app
-# Authentication is applied via router dependencies
-app.include_router(api_router)
-app.include_router(pages_router)
-
-
-if __name__ == "__main__":
- import uvicorn
- uvicorn.run(
- "backend.main:app",
- host=config['server']['host'],
- port=config['server']['port'],
- reload=config['server']['reload']
- )
+"""
+NoteDiscovery - Self-Hosted Markdown Knowledge Base
+Main FastAPI application
+"""
+
+from fastapi import FastAPI, HTTPException, UploadFile, File, Request, Form, Depends, APIRouter, Response
+from fastapi.staticfiles import StaticFiles
+from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials, APIKeyHeader
+from starlette.middleware.sessions import SessionMiddleware
+import os
+import yaml
+import json
+from pathlib import Path
+from typing import List, Optional
+import aiofiles
+from datetime import datetime
+import bcrypt
+import secrets
+from slowapi import Limiter, _rate_limit_exceeded_handler
+from slowapi.util import get_remote_address
+from slowapi.errors import RateLimitExceeded
+
+from .utils import (
+ scan_notes_fast_walk,
+ get_note_content,
+ save_note,
+ delete_note,
+ search_notes,
+ create_note_metadata,
+ ensure_directories,
+ create_folder,
+ move_note,
+ move_folder,
+ rename_folder,
+ delete_folder,
+ save_uploaded_image,
+ validate_path_security,
+ get_all_tags,
+ get_notes_by_tag,
+ get_templates,
+ get_template_content,
+ apply_template_placeholders,
+ paginate,
+ get_backlinks,
+)
+from .plugins import PluginManager
+from .themes import get_available_themes, get_theme_css
+from .share import (
+ create_share_token,
+ get_share_token,
+ get_share_info,
+ revoke_share_token,
+ get_note_by_token,
+ delete_token_for_note,
+ update_token_path,
+ get_all_shared_paths,
+)
+from .export import generate_export_html, embed_images_as_base64, convert_wikilinks_to_html, strip_frontmatter
+
+# Load configuration
+config_path = Path(__file__).parent.parent / "config.yaml"
+with open(config_path, 'r', encoding='utf-8') as f:
+ config = yaml.safe_load(f)
+
+# Load version from VERSION file (single source of truth)
+version_path = Path(__file__).parent.parent / "VERSION"
+if not version_path.exists():
+ raise FileNotFoundError("VERSION file not found. Please create it with the current version number.")
+with open(version_path, 'r', encoding='utf-8') as f:
+ version = f.read().strip()
+ config['app']['version'] = version
+
+# Environment variable overrides for authentication settings
+# Allows different configs for local vs production deployments
+if 'AUTHENTICATION_ENABLED' in os.environ:
+ auth_enabled = os.getenv('AUTHENTICATION_ENABLED', 'false').lower() in ('true', '1', 'yes')
+ config['authentication']['enabled'] = auth_enabled
+ print(f"๐ Authentication {'ENABLED' if auth_enabled else 'DISABLED'} (from AUTHENTICATION_ENABLED env var)")
+else:
+ print(f"๐ Authentication {'ENABLED' if config.get('authentication', {}).get('enabled', False) else 'DISABLED'} (from config.yaml)")
+# Read-only public mode (new)
+if 'READ_ONLY_ENABLED' in os.environ:
+ ro_enabled = os.getenv('READ_ONLY_ENABLED', 'false').lower() in ('true', '1', 'yes')
+ config.setdefault('read_only', {})['enabled'] = ro_enabled
+ print(f"๐ Read-only public access: {'ENABLED' if ro_enabled else 'DISABLED'} (from READ_ONLY_ENABLED env var)")
+else:
+ print(f"๐ Read-only public access: {'ENABLED' if config.get('read_only', {}).get('enabled', False) else 'DISABLED'} (from config.yaml)")
+# Password configuration priority:
+# 1. AUTHENTICATION_PASSWORD env var (hashed at startup)
+# 2. authentication.password in config.yaml (hashed at startup)
+# Default password is "admin" if nothing is configured
+if 'AUTHENTICATION_PASSWORD' in os.environ:
+ plain_password = os.getenv('AUTHENTICATION_PASSWORD', '').strip()
+ if plain_password:
+ config['authentication']['password_hash'] = bcrypt.hashpw(
+ plain_password.encode('utf-8'),
+ bcrypt.gensalt()
+ ).decode('utf-8')
+ print("๐ Password loaded from AUTHENTICATION_PASSWORD env var")
+ else:
+ print("โ ๏ธ WARNING: AUTHENTICATION_PASSWORD env var is empty - ignoring")
+elif config.get('authentication', {}).get('password', '').strip():
+ plain_password = config['authentication']['password'].strip()
+ config['authentication']['password_hash'] = bcrypt.hashpw(
+ plain_password.encode('utf-8'),
+ bcrypt.gensalt()
+ ).decode('utf-8')
+ del config['authentication']['password']
+ print("๐ Password loaded from config.yaml")
+
+# Allow secret key to be set via environment variable (for session security)
+if 'AUTHENTICATION_SECRET_KEY' in os.environ:
+ config['authentication']['secret_key'] = os.getenv('AUTHENTICATION_SECRET_KEY')
+ print("๐ Secret key loaded from AUTHENTICATION_SECRET_KEY env var")
+
+# API key configuration for external integrations (MCP servers, scripts, etc.)
+# Priority: AUTHENTICATION_API_KEY env var > authentication.api_key in config.yaml
+if 'AUTHENTICATION_API_KEY' in os.environ:
+ api_key_value = os.getenv('AUTHENTICATION_API_KEY', '').strip()
+ if api_key_value:
+ config['authentication']['api_key'] = api_key_value
+ print("๐ API key loaded from AUTHENTICATION_API_KEY env var")
+ else:
+ config['authentication']['api_key'] = ''
+elif config.get('authentication', {}).get('api_key', '').strip():
+ print("๐ API key loaded from config.yaml")
+else:
+ config['authentication']['api_key'] = ''
+
+# Warnings for missing authentication methods (only when auth is enabled)
+if config.get('authentication', {}).get('enabled', False):
+ _has_password = bool(config.get('authentication', {}).get('password_hash', ''))
+ _has_api_key = bool(config.get('authentication', {}).get('api_key', '').strip())
+ _secret_key = config.get('authentication', {}).get('secret_key', '')
+ _is_default_secret = _secret_key in ('', 'change_this_to_a_random_secret_key_in_production')
+
+ if not _has_password and not _has_api_key:
+ print("๐จ CRITICAL: Authentication enabled but NO auth methods configured - ALL access will be denied!")
+ else:
+ if not _has_password:
+ print("โ ๏ธ WARNING: No password configured - web UI login will not work")
+ if not _has_api_key:
+ print("โ ๏ธ WARNING: No API key configured - external integrations will require session cookies")
+
+ if _is_default_secret:
+ print("๐จ SECURITY WARNING: Using default secret_key - sessions can be forged! Change it in config.yaml")
+
+# OpenAPI tag metadata for grouping endpoints in Swagger UI
+tags_metadata = [
+ {"name": "Notes", "description": "Create, read, update, delete notes"},
+ {"name": "Folders", "description": "Folder management"},
+ {"name": "Media", "description": "Media files (images, audio, video, PDF)"},
+ {"name": "Search", "description": "Full-text search"},
+ {"name": "Sharing", "description": "Public note sharing via tokens"},
+ {"name": "Tags", "description": "Tag-based organization"},
+ {"name": "Templates", "description": "Note templates"},
+ {"name": "Themes", "description": "UI theme management"},
+ {"name": "Locales", "description": "Internationalization (i18n)"},
+ {"name": "Graph", "description": "Note relationship graph"},
+ {"name": "Plugins", "description": "Plugin management"},
+ {"name": "System", "description": "Health checks and configuration"},
+]
+
+# Initialize app
+app = FastAPI(
+ title=config['app']['name'],
+ version=config['app']['version'],
+ docs_url='/api', # Default is /docs
+ redoc_url=None, # Disable ReDoc at /redoc
+ openapi_tags=tags_metadata
+)
+
+# CORS middleware configuration
+# Use config.yaml to control allowed origins (default: ["*"] for self-hosted simplicity)
+allowed_origins = config.get('server', {}).get('allowed_origins', ["*"])
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=allowed_origins,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+print(f"๐ CORS allowed origins: {allowed_origins}")
+
+# ===========================================================
+# =================
+# Security Helpers
+# ============================================================================
+
+def safe_error_message(error: Exception, user_message: str = "An error occurred") -> str:
+ """
+ Return safe error message for API responses.
+ In debug mode, returns full error details.
+ In production, returns generic message and logs full details server-side.
+
+ Args:
+ error: The caught exception
+ user_message: User-friendly message to show in production
+
+ Returns:
+ Safe error message string
+ """
+ error_details = f"{type(error).__name__}: {str(error)}"
+
+ # Always log the full error server-side
+ print(f"โ ๏ธ [ERROR] {error_details}")
+
+ # In debug mode, return detailed error to help with development
+ if config.get('server', {}).get('debug', False):
+ return error_details
+
+ # In production, return generic message (full details already logged)
+ return user_message
+
+# Session middleware for authentication
+# Security: Session ID is regenerated after login to prevent session fixation attacks
+app.add_middleware(
+ SessionMiddleware,
+ secret_key=config.get('authentication', {}).get('secret_key', 'insecure_default_key_change_this'),
+ max_age=config.get('authentication', {}).get('session_max_age', 604800), # 7 days default
+ same_site='lax', # Prevents CSRF attacks
+ https_only=False # Set to True if using HTTPS in production
+)
+
+# Demo mode - Centralizes all demo-specific restrictions
+# When DEMO_MODE=true, enables rate limiting and other demo protections
+# Add additional demo restrictions here as needed (e.g., disable certain features)
+DEMO_MODE = os.getenv('DEMO_MODE', 'false').lower() in ('true', '1', 'yes')
+ALREADY_DONATED = os.getenv('ALREADY_DONATED', 'false').lower() in ('true', '1', 'yes')
+
+if DEMO_MODE:
+ # Enable rate limiting for demo deployments
+ limiter = Limiter(key_func=get_remote_address, default_limits=["200/hour"])
+ app.state.limiter = limiter
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
+ print("๐ญ DEMO MODE enabled - Rate limiting active")
+else:
+ # Production/self-hosted mode - no restrictions
+ # Create a dummy limiter that doesn't actually limit
+ class DummyLimiter:
+ def limit(self, *args, **kwargs):
+ def decorator(func):
+ return func
+ return decorator
+ limiter = DummyLimiter()
+
+# Ensure required directories exist
+ensure_directories(config)
+
+# Initialize plugin manager
+plugin_manager = PluginManager(config['storage']['plugins_dir'])
+
+# Run app startup hooks
+plugin_manager.run_hook('on_app_startup')
+
+# Mount static files
+static_path = Path(__file__).parent.parent / "frontend"
+app.mount("/static", StaticFiles(directory=static_path), name="static")
+
+# PWA Service Worker - must be served from root for proper scope
+@app.get("/sw.js", include_in_schema=False)
+@limiter.limit("30/minute")
+async def service_worker(request: Request):
+ """Serve the PWA service worker from root path for proper scope.
+ Injects the app version from VERSION file for cache invalidation."""
+ sw_path = static_path / "sw.js"
+ if sw_path.exists():
+ async with aiofiles.open(sw_path, 'r', encoding='utf-8') as f:
+ content = await f.read()
+ # Inject app version into cache name
+ content = content.replace('__APP_VERSION__', version)
+ return Response(content=content, media_type="application/javascript")
+ raise HTTPException(status_code=404, detail="Service worker not found")
+
+
+# ============================================================================
+# Custom Exception Handlers
+# ============================================================================
+
+@app.exception_handler(HTTPException)
+async def http_exception_handler(request: Request, exc: HTTPException):
+ """
+ Custom exception handler for HTTP exceptions.
+ Handles 401 errors specially:
+ - For API requests: return JSON error
+ - For page requests: redirect to login
+ """
+ # Only handle 401 errors specially
+ if exc.status_code == 401:
+ # Check if this is an API request
+ if request.url.path.startswith('/api/'):
+ return JSONResponse(
+ status_code=401,
+ content={"detail": exc.detail}
+ )
+
+ # For page requests, redirect to login
+ return RedirectResponse(url='/login', status_code=303)
+
+ # For all other HTTP exceptions, return default JSON response
+ return JSONResponse(
+ status_code=exc.status_code,
+ content={"detail": exc.detail}
+ )
+
+
+# ============================================================================
+# Authentication Helpers
+# ============================================================================
+
+# Security schemes for API authentication (auto_error=False for optional auth)
+# These are automatically added to OpenAPI docs (/api)
+bearer_scheme = HTTPBearer(auto_error=False, description="Bearer token authentication")
+api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False, description="API key header authentication")
+
+
+def auth_enabled() -> bool:
+ """Check if authentication is enabled in config"""
+ return config.get('authentication', {}).get('enabled', False)
+
+def read_only_enabled() -> bool:
+ """Check if public read-only mode is active"""
+ return config.get('read_only', {}).get('enabled', False)
+
+def get_api_key() -> str:
+ """Get the configured API key (empty string if not set)"""
+ return config.get('authentication', {}).get('api_key', '').strip()
+
+
+def verify_api_key(provided_key: str) -> bool:
+ """
+ Verify an API key using constant-time comparison.
+
+ Uses secrets.compare_digest to prevent timing attacks where an attacker
+ could determine the correct key by measuring response times.
+
+ Args:
+ provided_key: The API key provided in the request
+
+ Returns:
+ True if the key is valid, False otherwise
+ """
+ configured_key = get_api_key()
+
+ # No API key configured = API key auth disabled
+ if not configured_key:
+ return False
+
+ # Empty provided key is always invalid
+ if not provided_key:
+ return False
+
+ # Constant-time comparison to prevent timing attacks
+ try:
+ return secrets.compare_digest(provided_key.encode('utf-8'), configured_key.encode('utf-8'))
+ except Exception:
+ return False
+
+
+async def require_auth(
+ request: Request,
+ bearer_credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
+ x_api_key: Optional[str] = Depends(api_key_header)
+):
+ """
+ Dependency to require authentication on protected routes.
+
+ Supports two authentication methods:
+ 1. Session-based auth (web UI login with password)
+ 2. API key auth (for external integrations like MCP servers)
+
+ API key can be provided via:
+ - Authorization: Bearer YOUR_API_KEY
+ - X-API-Key: YOUR_API_KEY
+
+ Raises:
+ HTTPException: 401 if authentication fails
+ """
+ if not auth_enabled():
+ return # Auth disabled, allow all
+
+ # Public read-only mode: anyone can read (GET/HEAD/OPTIONS), writes still need auth
+ if read_only_enabled() and request.method.upper() in ("GET", "HEAD", "OPTIONS"):
+ return # public read access granted
+
+ # Method 1: Check Bearer token (parsed by FastAPI's HTTPBearer)
+ if bearer_credentials and verify_api_key(bearer_credentials.credentials):
+ return # Valid Bearer token - authenticated
+
+ # Method 2: Check X-API-Key header (parsed by FastAPI's APIKeyHeader)
+ if x_api_key and verify_api_key(x_api_key):
+ return # Valid API key header - authenticated
+
+ # Method 3: Check session-based authentication (web UI)
+ if request.session.get('authenticated'):
+ return # Valid session - authenticated
+
+ # No valid authentication method - deny access
+ raise HTTPException(status_code=401, detail="Not authenticated")
+
+
+def verify_password(password: str) -> bool:
+ """Verify password against stored hash"""
+ password_hash = config.get('authentication', {}).get('password_hash', '')
+ if not password_hash:
+ return False
+
+ try:
+ return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
+ except Exception as e:
+ print(f"Password verification error: {e}")
+ return False
+
+
+# ============================================================================
+# Authentication Routes
+# ============================================================================
+
+@app.get("/login", response_class=HTMLResponse, include_in_schema=False)
+async def login_page(request: Request, error: str = None):
+ """Serve the login page"""
+ if not auth_enabled():
+ return RedirectResponse(url="/", status_code=303)
+
+ # If already authenticated, redirect to home
+ if request.session.get('authenticated'):
+ return RedirectResponse(url="/", status_code=303)
+
+ # Serve login page
+ login_path = static_path / "login.html"
+ async with aiofiles.open(login_path, 'r', encoding='utf-8') as f:
+ content = await f.read()
+
+ # Inject app name throughout the login page
+ app_name = config['app']['name']
+ content = content.replace('NoteDiscovery', app_name)
+
+ return content
+
+
+@app.post("/login", include_in_schema=False)
+async def login(request: Request, password: str = Form(...)):
+ """Handle login form submission"""
+ if not auth_enabled():
+ return RedirectResponse(url="/", status_code=303)
+
+ # Verify password
+ if verify_password(password):
+ # Session regeneration: Clear old session to prevent session fixation attacks
+ # This forces the creation of a new session ID after successful authentication
+ request.session.clear()
+
+ # Set authenticated flag in the NEW session
+ request.session['authenticated'] = True
+ return RedirectResponse(url="/", status_code=303)
+ else:
+ # Redirect back to login with error code (frontend will translate)
+ return RedirectResponse(url="/login?error=incorrect_password", status_code=303)
+
+
+@app.get("/logout", include_in_schema=False)
+async def logout(request: Request):
+ """Log out the current user"""
+ request.session.clear()
+ return RedirectResponse(url="/login", status_code=303)
+
+# ============================================================================
+# Routers with Authentication
+# ============================================================================
+
+# Create API router with authentication dependency applied globally
+api_router = APIRouter(
+ prefix="/api",
+ dependencies=[Depends(require_auth)] # Apply auth to ALL routes in this router
+)
+
+# Create pages router with authentication dependency applied globally
+pages_router = APIRouter(
+ dependencies=[Depends(require_auth)] # Apply auth to ALL routes in this router
+)
+
+
+# ============================================================================
+# Application Routes (with auth via router dependencies)
+# ============================================================================
+
+@api_router.get("/config", tags=["System"])
+async def get_config():
+ """Get app configuration for frontend"""
+ return {
+ "name": config['app']['name'],
+ "version": config['app']['version'],
+ "searchEnabled": config['search']['enabled'],
+ "demoMode": DEMO_MODE, # Expose demo mode flag to frontend
+ "alreadyDonated": ALREADY_DONATED, # Hide support buttons if true
+ "authentication": {
+ "enabled": config.get('authentication', {}).get('enabled', False)
+ }
+ }
+
+
+@api_router.get("/themes", tags=["Themes"])
+async def list_themes():
+ """Get all available themes"""
+ themes_dir = Path(__file__).parent.parent / "themes"
+ themes = get_available_themes(str(themes_dir))
+ return {"themes": themes}
+
+
+@app.get("/api/themes/{theme_id}", tags=["Themes"]) # Don't use the router here, as we want this route unsecured
+async def get_theme(theme_id: str):
+ """Get CSS for a specific theme"""
+ themes_dir = Path(__file__).parent.parent / "themes"
+ css = get_theme_css(str(themes_dir), theme_id)
+
+ if not css:
+ raise HTTPException(status_code=404, detail="Theme not found")
+
+ return {"css": css, "theme_id": theme_id}
+
+
+# Locales endpoints (unauthenticated - needed for login page and initial load)
+@app.get("/api/locales", tags=["Locales"])
+async def get_available_locales():
+ """Get list of available locales"""
+ import json
+ locales_dir = Path(__file__).parent.parent / "locales"
+ locales = []
+
+ if locales_dir.exists():
+ for file in sorted(locales_dir.glob("*.json")):
+ try:
+ with open(file, 'r', encoding='utf-8') as f:
+ data = json.load(f)
+ meta = data.get('_meta', {})
+ locales.append({
+ "code": meta.get('code', file.stem),
+ "name": meta.get('name', file.stem),
+ "flag": meta.get('flag', '๐')
+ })
+ except (json.JSONDecodeError, IOError):
+ # Skip invalid locale files
+ continue
+
+ return {"locales": locales}
+
+
+@app.get("/api/locales/{locale_code}", tags=["Locales"])
+async def get_locale(locale_code: str):
+ """Get translations for a specific locale"""
+ import json
+ import re
+
+ # Security: Validate locale_code to prevent path traversal
+ # Only allow alphanumeric, hyphens, and underscores (e.g., "en", "pt-BR", "zh_CN")
+ if not re.match(r'^[a-zA-Z0-9_-]+$', locale_code):
+ raise HTTPException(status_code=400, detail="Invalid locale code")
+
+ locales_dir = Path(__file__).parent.parent / "locales"
+ locale_file = locales_dir / f"{locale_code}.json"
+
+ # Security: Ensure resolved path is still within locales directory
+ if not locale_file.resolve().is_relative_to(locales_dir.resolve()):
+ raise HTTPException(status_code=400, detail="Invalid locale code")
+
+ if not locale_file.exists():
+ raise HTTPException(status_code=404, detail="Locale not found")
+
+ try:
+ with open(locale_file, 'r', encoding='utf-8') as f:
+ translations = json.load(f)
+ return translations
+ except (json.JSONDecodeError, IOError) as e:
+ raise HTTPException(status_code=500, detail=f"Failed to load locale: {str(e)}")
+
+
+@api_router.post("/folders", tags=["Folders"])
+@limiter.limit("30/minute")
+async def create_new_folder(request: Request, data: dict):
+ """Create a new folder"""
+ try:
+ folder_path = data.get('path', '')
+ if not folder_path:
+ raise HTTPException(status_code=400, detail="Folder path required")
+
+ success = create_folder(config['storage']['notes_dir'], folder_path)
+
+ if not success:
+ raise HTTPException(status_code=500, detail="Failed to create folder")
+
+ return {
+ "success": True,
+ "path": folder_path,
+ "message": "Folder created successfully"
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to create folder"))
+
+
+@api_router.get("/media/{media_path:path}", tags=["Media"])
+async def get_media(media_path: str):
+ """
+ Serve a media file (image, audio, video, PDF) with authentication protection.
+ """
+ try:
+ from backend.utils import ALL_MEDIA_EXTENSIONS
+
+ notes_dir = config['storage']['notes_dir']
+ full_path = Path(notes_dir) / media_path
+
+ # Security: Validate path is within notes directory
+ if not validate_path_security(notes_dir, full_path):
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ # Check file exists
+ if not full_path.exists() or not full_path.is_file():
+ raise HTTPException(status_code=404, detail="File not found")
+
+ # Validate it's an allowed media file
+ if full_path.suffix.lower() not in ALL_MEDIA_EXTENSIONS:
+ raise HTTPException(status_code=400, detail="Not an allowed media file")
+
+ # Return the file
+ return FileResponse(full_path)
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load media file"))
+
+
+@api_router.post("/upload-media", tags=["Media"])
+@limiter.limit("20/minute")
+async def upload_media(request: Request, file: UploadFile = File(...), note_path: str = Form(...)):
+ """
+ Upload a media file (image, audio, video, PDF) and save it to the attachments directory.
+ Returns the relative path for markdown linking.
+ """
+ try:
+ from backend.utils import ALL_MEDIA_EXTENSIONS, get_media_type
+
+ # Allowed MIME types for each category
+ allowed_types = {
+ # Images
+ 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
+ # Audio
+ 'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/x-m4a',
+ # Video
+ 'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo',
+ # Documents
+ 'application/pdf',
+ }
+
+ # Get file extension
+ file_ext = Path(file.filename).suffix.lower() if file.filename else ''
+
+ if file.content_type not in allowed_types and file_ext not in ALL_MEDIA_EXTENSIONS:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Invalid file type. Allowed: images, audio (mp3), video (mp4), PDF. Got: {file.content_type}"
+ )
+
+ # Read file data
+ file_data = await file.read()
+
+ # Validate file size - different limits for different types
+ media_type = get_media_type(file.filename) if file.filename else None
+
+ # Size limits: images 10MB, audio 50MB, video 100MB, PDF 20MB
+ size_limits = {
+ 'image': 10 * 1024 * 1024,
+ 'audio': 50 * 1024 * 1024,
+ 'video': 100 * 1024 * 1024,
+ 'document': 20 * 1024 * 1024,
+ }
+ max_size = size_limits.get(media_type, 10 * 1024 * 1024)
+
+ if len(file_data) > max_size:
+ raise HTTPException(
+ status_code=400,
+ detail=f"File too large. Maximum size for {media_type or 'this type'}: {max_size // (1024*1024)}MB. Uploaded: {len(file_data) / 1024 / 1024:.2f}MB"
+ )
+
+ # Save the file (reusing image save function - it works for any file)
+ file_path = save_uploaded_image(
+ config['storage']['notes_dir'],
+ note_path,
+ file.filename,
+ file_data
+ )
+
+ if not file_path:
+ raise HTTPException(status_code=500, detail="Failed to save file")
+
+ return {
+ "success": True,
+ "path": file_path,
+ "filename": Path(file_path).name,
+ "type": media_type,
+ "message": f"{media_type.capitalize() if media_type else 'File'} uploaded successfully"
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to upload file"))
+
+
+@api_router.post("/media/move", tags=["Media"])
+@limiter.limit("30/minute")
+async def move_media_endpoint(request: Request, data: dict):
+ """Move a media file to a different folder"""
+ try:
+ from backend.utils import ALL_MEDIA_EXTENSIONS
+
+ old_path = data.get('oldPath', '')
+ new_path = data.get('newPath', '')
+
+ if not old_path or not new_path:
+ raise HTTPException(status_code=400, detail="Both oldPath and newPath required")
+
+ notes_dir = config['storage']['notes_dir']
+ old_full_path = Path(notes_dir) / old_path
+ new_full_path = Path(notes_dir) / new_path
+
+ # Security: Validate paths are within notes directory
+ if not validate_path_security(notes_dir, old_full_path):
+ raise HTTPException(status_code=403, detail="Invalid source path")
+ if not validate_path_security(notes_dir, new_full_path):
+ raise HTTPException(status_code=403, detail="Invalid destination path")
+
+ # Validate it's a media file
+ if old_full_path.suffix.lower() not in ALL_MEDIA_EXTENSIONS:
+ raise HTTPException(status_code=400, detail="Not a valid media file")
+
+ # Check source exists
+ if not old_full_path.exists():
+ raise HTTPException(status_code=404, detail=f"Media file not found: {old_path}")
+
+ # Check target doesn't exist
+ if new_full_path.exists():
+ raise HTTPException(status_code=409, detail=f"A file already exists at: {new_path}")
+
+ # Create parent directory if needed
+ new_full_path.parent.mkdir(parents=True, exist_ok=True)
+
+ # Move the file
+ import shutil
+ shutil.move(str(old_full_path), str(new_full_path))
+
+ return {"success": True, "message": "Media moved successfully", "newPath": new_path}
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move media file"))
+
+
+@api_router.post("/notes/move", tags=["Notes"])
+@limiter.limit("30/minute")
+async def move_note_endpoint(request: Request, data: dict):
+ """Move a note to a different folder"""
+ try:
+ old_path = data.get('oldPath', '')
+ new_path = data.get('newPath', '')
+
+ if not old_path or not new_path:
+ raise HTTPException(status_code=400, detail="Both oldPath and newPath required")
+
+ success, error_msg = move_note(config['storage']['notes_dir'], old_path, new_path)
+
+ if not success:
+ raise HTTPException(status_code=400, detail=error_msg or "Failed to move note")
+
+ # Update share token path if note was shared
+ update_token_path(config['storage']['notes_dir'], old_path, new_path)
+
+ # Run plugin hooks
+ plugin_manager.run_hook('on_note_save', note_path=new_path, content='')
+
+ return {
+ "success": True,
+ "oldPath": old_path,
+ "newPath": new_path,
+ "message": "Note moved successfully"
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move note"))
+
+
+@api_router.post("/folders/move", tags=["Folders"])
+@limiter.limit("20/minute")
+async def move_folder_endpoint(request: Request, data: dict):
+ """Move a folder to a different location"""
+ try:
+ old_path = data.get('oldPath', '')
+ new_path = data.get('newPath', '')
+
+ if not old_path or not new_path:
+ raise HTTPException(status_code=400, detail="Both oldPath and newPath required")
+
+ success, error_msg = move_folder(config['storage']['notes_dir'], old_path, new_path)
+
+ if not success:
+ raise HTTPException(status_code=400, detail=error_msg or "Failed to move folder")
+
+ return {
+ "success": True,
+ "oldPath": old_path,
+ "newPath": new_path,
+ "message": "Folder moved successfully"
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move folder"))
+
+
+@api_router.post("/folders/rename", tags=["Folders"])
+@limiter.limit("30/minute")
+async def rename_folder_endpoint(request: Request, data: dict):
+ """Rename a folder"""
+ try:
+ old_path = data.get('oldPath', '')
+ new_path = data.get('newPath', '')
+
+ if not old_path or not new_path:
+ raise HTTPException(status_code=400, detail="Both oldPath and newPath required")
+
+ success, error_msg = rename_folder(config['storage']['notes_dir'], old_path, new_path)
+
+ if not success:
+ raise HTTPException(status_code=400, detail=error_msg or "Failed to rename folder")
+
+ return {
+ "success": True,
+ "oldPath": old_path,
+ "newPath": new_path,
+ "message": "Folder renamed successfully"
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to rename folder"))
+
+
+@api_router.delete("/folders/{folder_path:path}", tags=["Folders"])
+@limiter.limit("20/minute")
+async def delete_folder_endpoint(request: Request, folder_path: str):
+ """Delete a folder and all its contents"""
+ try:
+ if not folder_path:
+ raise HTTPException(status_code=400, detail="Folder path required")
+
+ success = delete_folder(config['storage']['notes_dir'], folder_path)
+
+ if not success:
+ raise HTTPException(status_code=500, detail="Failed to delete folder")
+
+ return {
+ "success": True,
+ "path": folder_path,
+ "message": "Folder deleted successfully"
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to delete folder"))
+
+
+# --- Tags Endpoints ---
+
+@api_router.get("/tags", tags=["Tags"])
+async def list_tags():
+ """
+ Get all tags used across all notes with their counts.
+
+ Returns:
+ Dictionary mapping tag names to note counts
+ """
+ try:
+ tags = get_all_tags(config['storage']['notes_dir'])
+ return {"tags": tags}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load tags"))
+
+
+@api_router.get("/tags/{tag_name}", tags=["Tags"])
+async def get_notes_by_tag_endpoint(
+ tag_name: str,
+ limit: Optional[int] = None,
+ offset: int = 0
+):
+ """
+ Get all notes that have a specific tag with optional pagination.
+
+ Args:
+ tag_name: The tag to filter by (case-insensitive)
+ limit: Maximum number of notes to return (optional, no default limit)
+ offset: Number of notes to skip (default: 0)
+
+ Returns:
+ List of notes matching the tag
+
+ Examples:
+ GET /api/tags/docker -> All notes with #docker tag
+ GET /api/tags/docker?limit=10 -> First 10 notes with #docker tag
+ """
+ try:
+ notes = get_notes_by_tag(config['storage']['notes_dir'], tag_name)
+
+ # Apply pagination with consistent sorting by path
+ paginated = paginate(
+ items=notes,
+ limit=limit,
+ offset=offset,
+ sort_key=lambda x: x.get('path', '').lower()
+ )
+
+ response = {
+ "tag": tag_name,
+ "count": paginated.total,
+ "notes": paginated.items
+ }
+
+ # Include pagination metadata only when limit is specified
+ if limit is not None:
+ response["pagination"] = paginated.to_dict()
+
+ return response
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get notes by tag"))
+
+
+# --- Template Endpoints ---
+
+@api_router.get("/templates", tags=["Templates"])
+@limiter.limit("120/minute")
+async def list_templates(request: Request):
+ """
+ List all available templates from _templates folder.
+
+ Returns:
+ List of template metadata
+ """
+ try:
+ templates = get_templates(config['storage']['notes_dir'])
+ return {"templates": templates}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to list templates"))
+
+
+@api_router.get("/templates/{template_name}", tags=["Templates"])
+@limiter.limit("120/minute")
+async def get_template(request: Request, template_name: str):
+ """
+ Get content of a specific template.
+
+ Args:
+ template_name: Name of the template (without .md extension)
+
+ Returns:
+ Template name and content
+ """
+ try:
+ content = get_template_content(config['storage']['notes_dir'], template_name)
+
+ if content is None:
+ raise HTTPException(status_code=404, detail="Template not found")
+
+ return {
+ "name": template_name,
+ "content": content
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get template"))
+
+
+@api_router.post("/templates/create-note", tags=["Templates"])
+@limiter.limit("60/minute")
+async def create_note_from_template(request: Request, data: dict):
+ """
+ Create a new note from a template with placeholder replacement.
+
+ Args:
+ data: Dictionary containing templateName and notePath
+
+ Returns:
+ Success status, path, and created content
+ """
+ try:
+ template_name = data.get('templateName', '')
+ note_path = data.get('notePath', '')
+
+ if not template_name or not note_path:
+ raise HTTPException(status_code=400, detail="Template name and note path required")
+
+ # Get template content
+ template_content = get_template_content(config['storage']['notes_dir'], template_name)
+
+ if template_content is None:
+ raise HTTPException(status_code=404, detail="Template not found")
+
+ # Apply placeholder replacements
+ final_content = apply_template_placeholders(template_content, note_path)
+
+ # Run on_note_create hook BEFORE saving (allows plugins to modify initial content)
+ final_content = plugin_manager.run_hook_with_return(
+ 'on_note_create',
+ note_path=note_path,
+ initial_content=final_content
+ )
+
+ # Run on_note_save hook (can transform content, e.g., encrypt)
+ transformed_content = plugin_manager.run_hook('on_note_save', note_path=note_path, content=final_content)
+ if transformed_content is None:
+ transformed_content = final_content
+
+ # Save the note with the (potentially modified/transformed) content
+ success = save_note(config['storage']['notes_dir'], note_path, transformed_content)
+
+ if not success:
+ raise HTTPException(status_code=500, detail="Failed to create note from template")
+
+ return {
+ "success": True,
+ "path": note_path,
+ "message": "Note created from template successfully",
+ "content": final_content
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to create note from template"))
+
+
+# --- Notes Endpoints ---
+
+@api_router.get("/notes", tags=["Notes"])
+async def list_notes(
+ limit: Optional[int] = None,
+ offset: int = 0
+):
+ """
+ List all notes with metadata.
+
+ Supports optional pagination for API consumers (MCP, scripts):
+ - No parameters: Returns all notes (frontend compatibility)
+ - With limit: Returns paginated results with metadata
+
+ Args:
+ limit: Maximum number of notes to return (optional, no default limit)
+ offset: Number of notes to skip (default: 0)
+
+ Examples:
+ GET /api/notes -> All notes
+ GET /api/notes?limit=20 -> First 20 notes
+ GET /api/notes?limit=20&offset=20 -> Notes 21-40
+ """
+ try:
+ notes, folders = scan_notes_fast_walk(config['storage']['notes_dir'], include_media=True)
+
+ # Apply pagination with consistent sorting by path for stable results
+ result = paginate(
+ items=notes,
+ limit=limit,
+ offset=offset,
+ sort_key=lambda x: x.get('path', '').lower()
+ )
+
+ response = {
+ "notes": result.items,
+ "folders": folders
+ }
+
+ # Include pagination metadata only when limit is specified
+ if limit is not None:
+ response["pagination"] = result.to_dict()
+
+ return response
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to list notes"))
+
+
+@api_router.get("/notes/{note_path:path}", tags=["Notes"])
+async def get_note(note_path: str, include_backlinks: bool = True):
+ """Get a specific note's content with optional backlinks"""
+ try:
+ content = get_note_content(config['storage']['notes_dir'], note_path)
+ if content is None:
+ raise HTTPException(status_code=404, detail="Note not found")
+
+ # Run on_note_load hook (can transform content, e.g., decrypt)
+ transformed_content = plugin_manager.run_hook('on_note_load', note_path=note_path, content=content)
+ if transformed_content is not None:
+ content = transformed_content
+
+ response = {
+ "path": note_path,
+ "content": content,
+ "metadata": create_note_metadata(config['storage']['notes_dir'], note_path)
+ }
+
+ if include_backlinks:
+ response["backlinks"] = get_backlinks(config['storage']['notes_dir'], note_path)
+
+ return response
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load note"))
+
+
+@api_router.post("/notes/{note_path:path}", tags=["Notes"])
+@limiter.limit("60/minute")
+async def create_or_update_note(request: Request, note_path: str, content: dict):
+ """Create or update a note"""
+ try:
+ note_content = content.get('content', '')
+
+ # Check if this is a new note (doesn't exist yet)
+ existing_content = get_note_content(config['storage']['notes_dir'], note_path)
+ is_new_note = existing_content is None
+
+ # If creating a new note, run on_note_create hook to allow plugins to modify initial content
+ if is_new_note:
+ note_content = plugin_manager.run_hook_with_return(
+ 'on_note_create',
+ note_path=note_path,
+ initial_content=note_content
+ )
+
+ # Run on_note_save hook (can transform content, e.g., encrypt)
+ transformed_content = plugin_manager.run_hook('on_note_save', note_path=note_path, content=note_content)
+ if transformed_content is None:
+ transformed_content = note_content
+
+ success = save_note(config['storage']['notes_dir'], note_path, transformed_content)
+
+ if not success:
+ raise HTTPException(status_code=500, detail="Failed to save note")
+
+ return {
+ "success": True,
+ "path": note_path,
+ "message": "Note created successfully" if is_new_note else "Note saved successfully",
+ "content": note_content # Return the (potentially modified) content
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to save note"))
+
+
+@api_router.patch("/notes/{note_path:path}", tags=["Notes"])
+@limiter.limit("60/minute")
+async def append_to_note(request: Request, note_path: str, data: dict):
+ """
+ Append content to an existing note without overwriting.
+
+ Perfect for journals, logs, or collecting ideas incrementally.
+
+ Args:
+ note_path: Path to the note
+ data: Dictionary with 'content' to append and optional 'add_timestamp' boolean
+ """
+ try:
+ content_to_append = data.get('content', '')
+ add_timestamp = data.get('add_timestamp', False)
+
+ if not content_to_append:
+ raise HTTPException(status_code=400, detail="Content to append is required")
+
+ # Get existing content
+ existing_content = get_note_content(config['storage']['notes_dir'], note_path)
+
+ if existing_content is None:
+ raise HTTPException(status_code=404, detail="Note not found")
+
+ # Build the appended content
+ if add_timestamp:
+ from datetime import datetime
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
+ content_to_append = f"\n\n---\n\n**{timestamp}**\n\n{content_to_append}"
+ else:
+ content_to_append = f"\n\n{content_to_append}"
+
+ new_content = existing_content + content_to_append
+
+ # Run on_note_save hook
+ transformed_content = plugin_manager.run_hook('on_note_save', note_path=note_path, content=new_content)
+ if transformed_content is None:
+ transformed_content = new_content
+
+ success = save_note(config['storage']['notes_dir'], note_path, transformed_content)
+
+ if not success:
+ raise HTTPException(status_code=500, detail="Failed to append to note")
+
+ return {
+ "success": True,
+ "path": note_path,
+ "message": "Content appended successfully"
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to append to note"))
+
+
+@api_router.delete("/notes/{note_path:path}", tags=["Notes"])
+@limiter.limit("30/minute")
+async def remove_note(request: Request, note_path: str):
+ """Delete a note"""
+ try:
+ success = delete_note(config['storage']['notes_dir'], note_path)
+
+ if not success:
+ raise HTTPException(status_code=404, detail="Note not found")
+
+ # Clean up any share token for this note
+ delete_token_for_note(config['storage']['notes_dir'], note_path)
+
+ # Run plugin hooks
+ plugin_manager.run_hook('on_note_delete', note_path=note_path)
+
+ return {
+ "success": True,
+ "message": "Note deleted successfully"
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to delete note"))
+
+
+@api_router.get("/export/{note_path:path}", tags=["Export"])
+@limiter.limit("30/minute")
+async def export_note_to_html(request: Request, note_path: str, theme: Optional[str] = None, download: bool = True):
+ """
+ Export a note as a standalone HTML file.
+
+ The HTML includes all necessary CSS, MathJax, Mermaid, and syntax highlighting
+ for offline viewing. Images are embedded as base64.
+
+ Query Parameters:
+ theme: Optional theme name (defaults to current theme or 'light')
+ download: If true (default), returns as file download. If false, displays in browser with print button.
+
+ Returns:
+ HTML file (download or inline based on download parameter)
+ """
+ try:
+ notes_dir = Path(config['storage']['notes_dir'])
+
+ # Read note content
+ content = get_note_content(str(notes_dir), note_path)
+ if content is None:
+ raise HTTPException(status_code=404, detail="Note not found")
+
+ # Run on_note_load hook (can transform content, e.g., decrypt)
+ transformed_content = plugin_manager.run_hook('on_note_load', note_path=note_path, content=content)
+ if transformed_content is not None:
+ content = transformed_content
+
+ # Strip YAML frontmatter (like the preview does)
+ content = strip_frontmatter(content)
+
+ # Get note folder for resolving relative image paths
+ note_file_path = notes_dir / note_path
+ note_folder = note_file_path.parent
+
+ # Embed images as base64
+ content_with_images = embed_images_as_base64(content, note_folder, notes_dir)
+
+ # Convert wikilinks to decorative HTML links
+ content_with_links = convert_wikilinks_to_html(content_with_images)
+
+ # Get theme CSS
+ themes_dir = Path(__file__).parent.parent / "themes"
+ theme_name = theme or 'light'
+ theme_css = get_theme_css(str(themes_dir), theme_name)
+ if not theme_css:
+ theme_css = get_theme_css(str(themes_dir), "light")
+ theme_name = "light"
+
+ # Strip data-theme selector
+ theme_css = theme_css.replace(f':root[data-theme="{theme_name}"]', ':root')
+ theme_css = theme_css.replace(':root[data-theme="light"]', ':root')
+ theme_css = theme_css.replace(':root[data-theme="dark"]', ':root')
+
+ # Determine if dark theme
+ is_dark = 'dark' in theme_name.lower() or theme_name in ['dracula', 'nord', 'monokai', 'cobalt2', 'gruvbox-dark']
+
+ # Get note title
+ title = Path(note_path).stem
+
+ # Generate HTML (show print button only when not downloading)
+ html_content = generate_export_html(
+ title=title,
+ content=content_with_links,
+ theme_css=theme_css,
+ is_dark=is_dark,
+ show_print_button=not download
+ )
+
+ # Return as downloadable file or inline (for print preview)
+ if download:
+ filename = f"{title}.html"
+ return Response(
+ content=html_content,
+ media_type="text/html",
+ headers={
+ "Content-Disposition": f'attachment; filename="{filename}"'
+ }
+ )
+ else:
+ # Return inline for browser display (print preview)
+ return HTMLResponse(content=html_content)
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to export note"))
+
+
+@api_router.get("/search", tags=["Search"])
+async def search(
+ q: str,
+ limit: Optional[int] = None,
+ offset: int = 0
+):
+ """
+ Search notes by content with optional pagination.
+
+ Args:
+ q: Search query string
+ limit: Maximum number of results to return (optional, no default limit)
+ offset: Number of results to skip (default: 0)
+
+ Examples:
+ GET /api/search?q=docker -> All matching results
+ GET /api/search?q=docker&limit=10 -> First 10 results
+ GET /api/search?q=docker&limit=10&offset=10 -> Results 11-20
+ """
+ try:
+ if not config['search']['enabled']:
+ raise HTTPException(status_code=403, detail="Search is disabled")
+
+ # Handle empty query gracefully
+ if not q or not q.strip():
+ return {
+ "results": [],
+ "query": q,
+ "message": "No search term provided"
+ }
+
+ results = search_notes(config['storage']['notes_dir'], q)
+
+ # Run plugin hooks
+ plugin_manager.run_hook('on_search', query=q, results=results)
+
+ # Apply pagination with consistent sorting by path
+ paginated = paginate(
+ items=results,
+ limit=limit,
+ offset=offset,
+ sort_key=lambda x: x.get('path', '').lower()
+ )
+
+ response = {
+ "results": paginated.items,
+ "query": q
+ }
+
+ # Include pagination metadata only when limit is specified
+ if limit is not None:
+ response["pagination"] = paginated.to_dict()
+
+ return response
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Search failed"))
+
+
+@api_router.get("/graph", tags=["Graph"])
+async def get_graph():
+ """Get graph data for note visualization with wikilink and markdown link detection"""
+ try:
+ import re
+ import urllib.parse
+ notes, _folders = scan_notes_fast_walk(config['storage']['notes_dir'], include_media=False)
+ nodes = []
+ edges = []
+
+ # 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:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to generate graph data"))
+
+
+@api_router.get("/plugins", tags=["Plugins"])
+async def list_plugins():
+ """List all available plugins"""
+ return {"plugins": plugin_manager.list_plugins()}
+
+
+@api_router.get("/plugins/note_stats/calculate", tags=["Plugins"])
+async def calculate_note_stats(content: str):
+ """Calculate statistics for note content (if plugin enabled)"""
+ try:
+ plugin = plugin_manager.plugins.get('note_stats')
+ if not plugin or not plugin.enabled:
+ return {"enabled": False, "stats": None}
+
+ stats = plugin.calculate_stats(content)
+ return {"enabled": True, "stats": stats}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to calculate note statistics"))
+
+
+@api_router.post("/plugins/{plugin_name}/toggle", tags=["Plugins"])
+@limiter.limit("10/minute")
+async def toggle_plugin(request: Request, plugin_name: str, enabled: dict):
+ """Enable or disable a plugin"""
+ try:
+ is_enabled = enabled.get('enabled', False)
+ if is_enabled:
+ plugin_manager.enable_plugin(plugin_name)
+ else:
+ plugin_manager.disable_plugin(plugin_name)
+
+ return {
+ "success": True,
+ "plugin": plugin_name,
+ "enabled": is_enabled
+ }
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to toggle plugin"))
+
+
+# ============================================================================
+# Stats Endpoint (for dashboards)
+# ============================================================================
+
+@api_router.get("/stats", tags=["Stats"])
+@limiter.limit("30/minute")
+async def get_stats(request: Request):
+ """
+ Get application statistics at a glance.
+
+ Designed for dashboard widgets (e.g., Homepage) - lightweight and cached.
+ Returns counts of notes, folders, tags, templates, media, and other metadata.
+ """
+ try:
+ notes_dir = config['storage']['notes_dir']
+
+ # Get notes and folders (cached)
+ notes, folders = scan_notes_fast_walk(notes_dir, include_media=True)
+
+ # Separate notes from media
+ note_items = [n for n in notes if n.get('type') == 'note']
+ media_items = [n for n in notes if n.get('type') != 'note']
+
+ # Count unique tags
+ all_tags = set()
+ for note in note_items:
+ all_tags.update(note.get('tags', []))
+
+ # Get templates count
+ templates = get_templates(notes_dir)
+
+ # Calculate total size
+ total_size = sum(n.get('size', 0) for n in notes)
+
+ # Get last modified (notes are already sorted by modified desc)
+ last_modified = note_items[0].get('modified') if note_items else None
+
+ # Count enabled plugins
+ enabled_plugins = sum(1 for p in plugin_manager.plugins.values() if p.enabled)
+
+ # Read version
+ version = "unknown"
+ version_file = Path(__file__).parent.parent / "VERSION"
+ if version_file.exists():
+ version = version_file.read_text().strip()
+
+ return {
+ "notes_count": len(note_items),
+ "folders_count": len(folders),
+ "tags_count": len(all_tags),
+ "templates_count": len(templates),
+ "media_count": len(media_items),
+ "total_size_bytes": total_size,
+ "last_modified": last_modified,
+ "plugins_enabled": enabled_plugins,
+ "version": version
+ }
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get stats"))
+
+
+# ============================================================================
+# Share Token Endpoints (authenticated)
+# ============================================================================
+
+@api_router.post("/share/{note_path:path}", tags=["Sharing"])
+@limiter.limit("30/minute")
+async def create_share(request: Request, note_path: str, data: dict = None):
+ """
+ Create a share token for a note.
+ Returns the share URL that can be accessed without authentication.
+ Optionally accepts { "theme": "theme-name" } to set the display theme.
+ """
+ try:
+ notes_dir = config['storage']['notes_dir']
+
+ # Get theme from request body (default to light)
+ theme = "light"
+ if data and isinstance(data, dict):
+ theme = data.get('theme', 'light')
+
+ # Add .md extension if not present
+ if not note_path.endswith('.md'):
+ note_path = f"{note_path}.md"
+
+ # Check if note exists
+ content = get_note_content(notes_dir, note_path)
+ if content is None:
+ raise HTTPException(status_code=404, detail="Note not found")
+
+ # Create or get existing token (with theme)
+ token = create_share_token(notes_dir, note_path, theme)
+ if not token:
+ raise HTTPException(status_code=500, detail="Failed to create share token")
+
+ # Build share URL
+ base_url = str(request.base_url).rstrip('/')
+ share_url = f"{base_url}/share/{token}"
+
+ return {
+ "success": True,
+ "token": token,
+ "url": share_url,
+ "path": note_path,
+ "theme": theme
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to create share"))
+
+
+@api_router.get("/share/{note_path:path}", tags=["Sharing"])
+@limiter.limit("120/minute")
+async def get_share_status(request: Request, note_path: str):
+ """
+ Get the share status for a note.
+ Returns whether the note is shared and its share URL if so.
+ """
+ try:
+ notes_dir = config['storage']['notes_dir']
+
+ # Add .md extension if not present
+ if not note_path.endswith('.md'):
+ note_path = f"{note_path}.md"
+
+ # Get share info
+ info = get_share_info(notes_dir, note_path)
+
+ if info.get('shared'):
+ base_url = str(request.base_url).rstrip('/')
+ info['url'] = f"{base_url}/share/{info['token']}"
+
+ return info
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get share status"))
+
+
+@api_router.get("/shared-notes", tags=["Sharing"])
+@limiter.limit("60/minute")
+async def list_shared_notes(request: Request):
+ """
+ Get a list of all currently shared note paths.
+ Used for displaying share indicators in the UI.
+ """
+ try:
+ notes_dir = config['storage']['notes_dir']
+ shared_paths = get_all_shared_paths(notes_dir)
+ return {"paths": shared_paths}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get shared notes"))
+
+
+@api_router.delete("/share/{note_path:path}", tags=["Sharing"])
+@limiter.limit("30/minute")
+async def delete_share(request: Request, note_path: str):
+ """
+ Revoke sharing for a note (delete the share token).
+ """
+ try:
+ notes_dir = config['storage']['notes_dir']
+
+ # Add .md extension if not present
+ if not note_path.endswith('.md'):
+ note_path = f"{note_path}.md"
+
+ # Revoke token
+ success = revoke_share_token(notes_dir, note_path)
+
+ return {
+ "success": success,
+ "message": "Share revoked" if success else "Note was not shared"
+ }
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to revoke share"))
+
+
+# ============================================================================
+# Public Share Endpoint (no authentication required)
+# ============================================================================
+
+@app.get("/share/{token}", response_class=HTMLResponse, tags=["Sharing"])
+@limiter.limit("60/minute")
+async def view_shared_note(request: Request, token: str):
+ """
+ View a shared note by its token.
+ No authentication required - anyone with the token can view.
+ """
+ try:
+ notes_dir = Path(config['storage']['notes_dir'])
+
+ # Look up note by token (returns dict with path and theme)
+ share_info = get_note_by_token(str(notes_dir), token)
+ if not share_info:
+ raise HTTPException(status_code=404, detail="Shared note not found or link expired")
+
+ note_path = share_info['path']
+ theme = share_info.get('theme', 'light')
+
+ # Read note content
+ content = get_note_content(str(notes_dir), note_path)
+ if content is None:
+ # Note was deleted but token still exists - clean up
+ delete_token_for_note(str(notes_dir), note_path)
+ raise HTTPException(status_code=404, detail="Note no longer exists")
+
+ # Strip YAML frontmatter (like the preview does)
+ content = strip_frontmatter(content)
+
+ # Get note folder for resolving relative image paths
+ note_file_path = notes_dir / note_path
+ note_folder = note_file_path.parent
+
+ # Embed images as base64
+ content_with_images = embed_images_as_base64(content, note_folder, notes_dir)
+
+ # Convert wikilinks to decorative HTML links
+ content_with_links = convert_wikilinks_to_html(content_with_images)
+
+ # Use the theme that was set when sharing
+ themes_dir = Path(__file__).parent.parent / "themes"
+ theme_css = get_theme_css(str(themes_dir), theme)
+ if not theme_css:
+ theme_css = get_theme_css(str(themes_dir), "light")
+ theme = "light"
+
+ # Strip data-theme selector
+ theme_css = theme_css.replace(f':root[data-theme="{theme}"]', ':root')
+ theme_css = theme_css.replace(':root[data-theme="light"]', ':root')
+ theme_css = theme_css.replace(':root[data-theme="dark"]', ':root')
+
+ # Determine if dark theme
+ is_dark = 'dark' in theme.lower() or theme in ['dracula', 'nord', 'monokai', 'cobalt2', 'gruvbox-dark']
+
+ # Get note title
+ title = Path(note_path).stem
+
+ # Generate HTML
+ html_content = generate_export_html(
+ title=title,
+ content=content_with_links,
+ theme_css=theme_css,
+ is_dark=is_dark
+ )
+
+ return HTMLResponse(content=html_content)
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load shared note"))
+
+
+@app.get("/health", tags=["System"])
+async def health_check():
+ """Health check endpoint"""
+ return {
+ "status": "healthy",
+ "app": config['app']['name'],
+ "version": config['app']['version']
+ }
+
+
+# Catch-all route for SPA (Single Page Application) routing
+# This allows URLs like /folder/note to work for direct navigation
+@pages_router.get("/{full_path:path}", response_class=HTMLResponse)
+@limiter.limit("120/minute")
+async def catch_all(full_path: str, request: Request):
+ """
+ Serve index.html for all non-API routes (including root /).
+ This enables client-side routing (e.g., /folder/note)
+ """
+ # Skip if it's an API route or static file (shouldn't reach here, but just in case)
+ if full_path.startswith('api/') or full_path.startswith('static/'):
+ raise HTTPException(status_code=404, detail="Not found")
+
+ # Serve index.html with app name injected
+ index_path = static_path / "index.html"
+ async with aiofiles.open(index_path, 'r', encoding='utf-8') as f:
+ content = await f.read()
+ app_name = config['app']['name']
+ return content.replace('NoteDiscovery', f'{app_name}')
+
+
+# ============================================================================
+# Register Routers
+# ============================================================================
+
+# Register routers with the main app
+# Authentication is applied via router dependencies
+app.include_router(api_router)
+app.include_router(pages_router)
+
+
+if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run(
+ "backend.main:app",
+ host=config['server']['host'],
+ port=config['server']['port'],
+ reload=config['server']['reload']
+ )
diff --git a/config.yaml b/config.yaml
index a62458a..76f96f9 100644
--- a/config.yaml
+++ b/config.yaml
@@ -1,55 +1,60 @@
-# NoteDiscovery Configuration
-
-app:
- name: "NoteDiscovery"
-
-server:
- host: "0.0.0.0"
- port: 8000
- reload: false # Set to true for development
-
- # CORS (Cross-Origin Resource Sharing) configuration
- # For self-hosted use, "*" is fine. For production, specify allowed domains.
- # Examples: ["http://localhost:8000", "https://yourdomain.com"]
- allowed_origins: ["*"]
-
- # Debug mode - shows detailed error messages (DISABLE in production!)
- debug: false
-
-storage:
- notes_dir: "./data"
- plugins_dir: "./plugins"
-
-search:
- enabled: true
-
-authentication:
- # Authentication settings
- # Set enabled to true to require login
- enabled: false
-
- # โ ๏ธ SECURITY WARNING: Change these values before exposing to the internet!
- # Default values below are for LOCAL TESTING ONLY
-
- # Session secret key - CHANGE THIS TO A RANDOM STRING!
- # Generate with: python -c "import secrets; print(secrets.token_hex(32))"
- secret_key: "change_this_to_a_random_secret_key_in_production"
-
- # Password (hashed automatically at startup)
- # โ ๏ธ Default password is "admin" - CHANGE THIS for production!
- password: "admin"
-
- # Session expiry in seconds (default: 7 days)
- session_max_age: 604800
-
- # API Key Authentication (for external integrations)
- # Usage (choose one):
- # Authorization: Bearer YOUR_API_KEY
- # X-API-Key: YOUR_API_KEY
- #
- # Generate a secure key with:
- # python -c "import secrets; print(secrets.token_hex(32))"
- #
- # Leave empty to disable API key authentication (only session auth works)
- api_key: ""
-
+# NoteDiscovery Configuration
+
+app:
+ name: "NoteDiscovery"
+
+server:
+ host: "0.0.0.0"
+ port: 8000
+ reload: false # Set to true for development
+
+ # CORS (Cross-Origin Resource Sharing) configuration
+ # For self-hosted use, "*" is fine. For production, specify allowed domains.
+ # Examples: ["http://localhost:8000", "https://yourdomain.com"]
+ allowed_origins: ["*"]
+
+ # Debug mode - shows detailed error messages (DISABLE in production!)
+ debug: false
+
+storage:
+ notes_dir: "./data"
+ plugins_dir: "./plugins"
+
+search:
+ enabled: true
+
+authentication:
+ # Authentication settings
+ # Set enabled to true to require login
+ enabled: true
+
+ # โ ๏ธ SECURITY WARNING: Change these values before exposing to the internet!
+ # Default values below are for LOCAL TESTING ONLY
+
+ # Session secret key - CHANGE THIS TO A RANDOM STRING!
+ # Generate with: python -c "import secrets; print(secrets.token_hex(32))"
+ secret_key: "change_this_to_a_random_secret_key_in_production"
+
+ # Password (hashed automatically at startup)
+ # โ ๏ธ Default password is "admin" - CHANGE THIS for production!
+ password: "admin"
+
+ # Session expiry in seconds (default: 7 days)
+ session_max_age: 604800
+
+ # API Key Authentication (for external integrations)
+ # Usage (choose one):
+ # Authorization: Bearer YOUR_API_KEY
+ # X-API-Key: YOUR_API_KEY
+ #
+ # Generate a secure key with:
+ # python -c "import secrets; print(secrets.token_hex(32))"
+ #
+ # Leave empty to disable API key authentication (only session auth works)
+ api_key: ""
+
+# Public read-only access (new feature)
+# When true: anyone can read notes (GET requests are public)
+# Writes (save, delete, upload, etc.) still require valid API key or login
+read_only:
+ enabled: true
diff --git a/frontend/app.js b/frontend/app.js
index 4acd1a0..18b1352 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -1,5804 +1,5824 @@
-// NoteDiscovery Frontend Application
-
-// Configuration constants
-const CONFIG = {
- AUTOSAVE_DELAY: 1000, // ms - Delay before triggering autosave
- SEARCH_DEBOUNCE_DELAY: 500, // ms - Delay before running note search while typing
- SAVE_INDICATOR_DURATION: 2000, // ms - How long to show "saved" indicator
- SCROLL_SYNC_DELAY: 50, // ms - Delay to prevent scroll sync interference
- SCROLL_SYNC_MAX_RETRIES: 10, // Maximum attempts to find editor/preview elements
- SCROLL_SYNC_RETRY_INTERVAL: 100, // ms - Time between setupScrollSync retries
- MAX_UNDO_HISTORY: 50, // Maximum number of undo steps to keep
- DEFAULT_SIDEBAR_WIDTH: 256, // px - Default sidebar width (w-64 in Tailwind)
-};
-
-// localStorage settings configuration - centralized definition of all persisted settings
-const LOCAL_SETTINGS = {
- // Boolean settings
- syntaxHighlightEnabled: { key: 'syntaxHighlightEnabled', type: 'boolean', default: false },
- readableLineLength: { key: 'readableLineLength', type: 'boolean', default: true },
- favoritesExpanded: { key: 'favoritesExpanded', type: 'boolean', default: true },
- tagsExpanded: { key: 'tagsExpanded', type: 'boolean', default: false },
- hideUnderscoreFolders: { key: 'hideUnderscoreFolders', type: 'boolean', default: false },
- tabInsertsTab: { key: 'tabInsertsTab', type: 'boolean', default: false },
- // Number settings with validation
- sidebarWidth: { key: 'sidebarWidth', type: 'number', default: CONFIG.DEFAULT_SIDEBAR_WIDTH, min: 200, max: 600 },
- editorWidth: { key: 'editorWidth', type: 'number', default: 50, min: 20, max: 80 },
- // String settings with validation
- viewMode: { key: 'viewMode', type: 'string', default: 'split', valid: ['edit', 'split', 'preview'] },
- // JSON settings
- favorites: { key: 'noteFavorites', type: 'json', default: [] },
-};
-
-// Centralized error handling
-const ErrorHandler = {
- /**
- * Handle errors consistently across the app
- * @param {string} operation - The operation that failed (e.g., "load notes", "save note")
- * @param {Error} error - The error object
- * @param {boolean} showAlert - Whether to show an alert to the user
- */
- handle(operation, error, showAlert = true) {
- // Always log to console for debugging
- console.error(`Failed to ${operation}:`, error);
-
- // Show user-friendly alert if requested
- if (showAlert) {
- // Note: ErrorHandler doesn't have access to Alpine's t() function
- // This message remains in English as a fallback
- alert(`Failed to ${operation}. Please try again.`);
- }
- }
-};
-
-/**
- * Centralized filename validation
- * Supports Unicode characters (international text) but blocks dangerous filesystem characters.
- * Does NOT silently modify filenames - validates and returns status.
- */
-const FilenameValidator = {
- // Characters that are forbidden in filenames across Windows/macOS/Linux
- // Windows: \ / : * ? " < > |
- // macOS: / :
- // Linux: / \0
- // Common set to block (including control characters)
- FORBIDDEN_CHARS: /[\\/:*?"<>|\x00-\x1f]/,
-
- // For display purposes - human readable list
- FORBIDDEN_CHARS_DISPLAY: '\\ / : * ? " < > |',
-
- /**
- * Validate a filename (single segment, no path separators)
- * @param {string} name - The filename to validate
- * @returns {{ valid: boolean, error?: string, sanitized?: string }}
- */
- validateFilename(name) {
- if (!name || typeof name !== 'string') {
- return { valid: false, error: 'empty' };
- }
-
- const trimmed = name.trim();
- if (!trimmed) {
- return { valid: false, error: 'empty' };
- }
-
- // Check for forbidden characters
- if (this.FORBIDDEN_CHARS.test(trimmed)) {
- return {
- valid: false,
- error: 'forbidden_chars',
- forbiddenChars: this.FORBIDDEN_CHARS_DISPLAY
- };
- }
-
- // Check for reserved Windows names (case-insensitive)
- const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
- if (reservedNames.test(trimmed)) {
- return { valid: false, error: 'reserved_name' };
- }
-
- // Check for names starting/ending with dots or spaces (problematic on some systems)
- if (trimmed.startsWith('.') && trimmed.length === 1) {
- return { valid: false, error: 'invalid_dot' };
- }
- if (trimmed.endsWith('.') || trimmed.endsWith(' ')) {
- return { valid: false, error: 'trailing_dot_space' };
- }
-
- return { valid: true, sanitized: trimmed };
- },
-
- /**
- * Validate a path (may contain forward slashes for folder separators)
- * @param {string} path - The path to validate
- * @returns {{ valid: boolean, error?: string, sanitized?: string }}
- */
- validatePath(path) {
- if (!path || typeof path !== 'string') {
- return { valid: false, error: 'empty' };
- }
-
- const trimmed = path.trim();
- if (!trimmed) {
- return { valid: false, error: 'empty' };
- }
-
- // Split by forward slash and validate each segment
- const segments = trimmed.split('/').filter(s => s.length > 0);
- if (segments.length === 0) {
- return { valid: false, error: 'empty' };
- }
-
- for (const segment of segments) {
- const result = this.validateFilename(segment);
- if (!result.valid) {
- return result;
- }
- }
-
- // Rebuild path without empty segments
- return { valid: true, sanitized: segments.join('/') };
- }
-};
-
-function noteApp() {
- return {
- // App state
- appName: 'NoteDiscovery',
- appVersion: '0.0.0',
- authEnabled: false,
- demoMode: false,
- alreadyDonated: false,
- notes: [],
- currentNote: '',
- currentNoteName: '',
- noteContent: '',
- viewMode: 'split', // 'edit', 'split', 'preview'
- searchQuery: '',
-
- // Graph state (separate overlay, doesn't affect viewMode)
- showGraph: false,
- graphInstance: null,
- graphLoaded: false,
- graphData: null,
- searchResults: [],
- currentSearchHighlight: '', // Track current highlighted search term
- currentMatchIndex: 0, // Current match being viewed
- totalMatches: 0, // Total number of matches in the note
- isSaving: false,
- lastSaved: false,
- linkCopied: false,
- zenMode: false,
- previousViewMode: 'split',
- favorites: [],
- favoritesSet: new Set(), // For O(1) lookups
- favoritesExpanded: true,
- saveTimeout: null,
-
- // Note lookup maps for O(1) wikilink resolution (built on loadNotes)
- _noteLookup: {
- byPath: new Map(), // path -> true
- byPathLower: new Map(), // path.toLowerCase() -> true
- byName: new Map(), // name (without .md) -> true
- byNameLower: new Map(), // name.toLowerCase() -> true
- byEndPath: new Map(), // '/filename' and '/filename.md' -> true
- },
-
- // Media lookup map for O(1) media wikilink resolution (built on loadNotes)
- // Maps media filename (case-insensitive) -> full path
- _mediaLookup: new Map(),
-
- // Preview rendering debounce
- _previewDebounceTimeout: null,
- _lastRenderedContent: '',
- _cachedRenderedHTML: '',
- _mathDebounceTimeout: null,
- _mermaidDebounceTimeout: null,
-
- // Theme state
- currentTheme: 'light',
- availableThemes: [],
-
- // Locale/i18n state
- currentLocale: localStorage.getItem('locale') || 'en-US',
- availableLocales: [],
- // Translations loaded from backend (preloaded before Alpine init via window.__preloadedTranslations)
- translations: window.__preloadedTranslations || {},
-
- // Syntax highlighting
- syntaxHighlightEnabled: false,
- syntaxHighlightTimeout: null,
-
- // Readable line length (preview max-width)
- readableLineLength: true,
-
- // Hide underscore-prefixed folders (_attachments, _templates) from sidebar
- // Read synchronously to prevent flash on initial render
- hideUnderscoreFolders: localStorage.getItem('hideUnderscoreFolders') === 'true',
-
- // Tab key inserts tab character instead of changing focus
- tabInsertsTab: localStorage.getItem('tabInsertsTab') === 'true',
-
- // Icon rail / panel state
- activePanel: 'files', // 'files', 'search', 'tags', 'settings'
-
- // Folder state
- folderTree: [],
- allFolders: [],
- expandedFolders: new Set(),
- dragOverFolder: null, // Track which folder is being hovered during drag
-
- // Tags state
- allTags: {},
- selectedTags: [],
- tagsExpanded: false,
- tagReloadTimeout: null, // For debouncing tag reloads
-
- // Search state
- searchDebounceTimeout: null,
- isSearching: false,
-
- // Outline (TOC) state
- outline: [], // [{level: 1, text: 'Heading', slug: 'heading'}, ...]
-
- // Backlinks state
- backlinks: [], // [{path: 'note.md', name: 'Note', references: [{line_number: 5, context: '...', type: 'wikilink'}]}]
-
- // Scroll sync state
- isScrolling: false,
-
- // Unified drag state for notes, folders, and media
- draggedItem: null, // { path: string, type: 'note' | 'folder' | 'image' | 'audio' | 'video' | 'document' }
- dropTarget: null, // 'editor' | 'folder' | null
-
- // Undo/Redo history
- undoHistory: [],
- redoHistory: [],
- maxHistorySize: CONFIG.MAX_UNDO_HISTORY,
- isUndoRedo: false,
- hasPendingHistoryChanges: false,
-
- // Stats plugin state
- statsPluginEnabled: false,
- noteStats: null,
- statsExpanded: false,
-
- // Note metadata (frontmatter) state
- noteMetadata: null,
- metadataExpanded: false,
- _lastFrontmatter: null, // Cache to avoid re-parsing unchanged frontmatter
-
- // Sidebar resize state
- sidebarWidth: CONFIG.DEFAULT_SIDEBAR_WIDTH,
- isResizing: false,
-
- // Mobile sidebar state
- mobileSidebarOpen: false,
-
- // Split view resize state
- editorWidth: 50, // percentage
- isResizingSplit: false,
-
- // Dropdown state
- showNewDropdown: false,
- dropdownTargetFolder: null, // Folder context for "New" dropdown ('' = root, null = not set)
- dropdownPosition: { top: 0, left: 0 }, // Position for contextual dropdown
-
- // Template state
- showTemplateModal: false,
- availableTemplates: [],
- selectedTemplate: '',
- newTemplateNoteName: '',
-
- // Share state
- showShareModal: false,
- shareInfo: null,
- shareLoading: false,
- showShareQR: false,
- shareLinkCopied: false,
- _sharedNotePaths: new Set(), // O(1) lookup for shared note indicators
-
- // Quick Switcher state (Ctrl+Alt+P)
- showQuickSwitcher: false,
- quickSwitcherQuery: '',
- quickSwitcherIndex: 0,
- quickSwitcherResults: [],
-
- // Homepage state
- selectedHomepageFolder: '',
- _homepageCache: {
- folderPath: null,
- notes: null,
- folders: null,
- breadcrumb: null
- },
-
- // Homepage constants
- HOMEPAGE_MAX_NOTES: 50,
-
- // Computed-like helpers for homepage (cached for performance)
- homepageNotes() {
- // Return cached result if folder hasn't changed
- if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.notes) {
- return this._homepageCache.notes;
- }
-
- if (!this.folderTree || typeof this.folderTree !== 'object') {
- return [];
- }
-
- const folderNode = this.getFolderNode(this.selectedHomepageFolder || '');
- const result = (folderNode && Array.isArray(folderNode.notes)) ? folderNode.notes : [];
-
- // Cache the result
- this._homepageCache.notes = result;
- this._homepageCache.folderPath = this.selectedHomepageFolder;
-
- return result;
- },
-
- homepageFolders() {
- // Return cached result if folder hasn't changed
- if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.folders) {
- return this._homepageCache.folders;
- }
-
- if (!this.folderTree || typeof this.folderTree !== 'object') {
- return [];
- }
-
- // Get child folders
- let childFolders = [];
- if (!this.selectedHomepageFolder) {
- // Root level: all top-level folders
- childFolders = Object.entries(this.folderTree)
- .filter(([key]) => key !== '__root__')
- .map(([, folder]) => folder);
- } else {
- // Inside a folder: get its children
- const parentFolder = this.getFolderNode(this.selectedHomepageFolder);
- if (parentFolder && parentFolder.children) {
- childFolders = Object.values(parentFolder.children);
- }
- }
-
- // Map to simplified structure (note count already cached in folder node)
- const result = childFolders
- .map(folder => ({
- name: folder.name,
- path: folder.path,
- noteCount: folder.noteCount || 0 // Use pre-calculated count
- }))
- .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
-
- // Cache the result
- this._homepageCache.folders = result;
- this._homepageCache.folderPath = this.selectedHomepageFolder;
-
- return result;
- },
-
- homepageBreadcrumb() {
- // Return cached result if folder hasn't changed
- if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.breadcrumb) {
- return this._homepageCache.breadcrumb;
- }
-
- const breadcrumb = [{ name: this.t('homepage.title'), path: '' }];
-
- if (this.selectedHomepageFolder) {
- const parts = this.selectedHomepageFolder.split('/').filter(Boolean);
- let currentPath = '';
-
- parts.forEach(part => {
- currentPath = currentPath ? `${currentPath}/${part}` : part;
- breadcrumb.push({ name: part, path: currentPath });
- });
- }
-
- // Cache the result
- this._homepageCache.breadcrumb = breadcrumb;
- this._homepageCache.folderPath = this.selectedHomepageFolder;
-
- return breadcrumb;
- },
-
- // Helper: Format file size nicely
- formatSize(bytes) {
- if (!bytes) return '0 B';
- const k = 1024;
- const sizes = ['B', 'KB', 'MB', 'GB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
- },
-
- // Helper: Format date using current locale
- formatDate(dateStr) {
- if (!dateStr) return '';
- const date = new Date(dateStr);
- if (isNaN(date.getTime())) return '';
- return date.toLocaleDateString(this.currentLocale, {
- year: 'numeric',
- month: 'short',
- day: 'numeric'
- });
- },
-
- getFolderNode(folderPath = '') {
- if (!this.folderTree || typeof this.folderTree !== 'object') {
- return null;
- }
-
- if (!folderPath) {
- return this.folderTree['__root__'] || { name: '', path: '', children: {}, notes: [], noteCount: 0 };
- }
-
- const parts = folderPath.split('/').filter(Boolean);
- let currentLevel = this.folderTree;
- let node = null;
-
- for (const part of parts) {
- if (!currentLevel[part]) {
- return null;
- }
- node = currentLevel[part];
- currentLevel = node.children || {};
- }
-
- return node;
- },
-
- // Check if app is empty (no notes and no folders)
- get isAppEmpty() {
- const notesArray = Array.isArray(this.notes) ? this.notes : [];
- const foldersArray = Array.isArray(this.allFolders) ? this.allFolders : [];
- return notesArray.length === 0 && foldersArray.length === 0;
- },
-
- // Mermaid state cache
- lastMermaidTheme: null,
-
- // Media viewer state
- currentMedia: '', // Path to current media file (kept as 'currentMedia' for compatibility)
- currentMediaType: 'image', // 'image', 'audio', 'video', 'document'
-
- // DOM element cache (to avoid repeated querySelector calls)
- _domCache: {
- editor: null,
- previewContainer: null,
- previewContent: null
- },
-
- // Initialize app
- async init() {
- // Prevent double initialization (Alpine.js may call x-init twice in some cases)
- if (window.__noteapp_initialized) return;
- window.__noteapp_initialized = true;
-
- // Store global reference for native event handlers in x-html content
- window.$root = this;
-
- // ESC key to cancel drag operations
- document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape' && this.draggedItem) {
- this.cancelDrag();
- }
- });
-
- await this.loadConfig();
- await this.loadThemes();
- await this.initTheme();
- await this.loadAvailableLocales();
- // Note: Translations are preloaded synchronously before Alpine init (see index.html)
- // loadLocale() is only called when user changes language from settings
- await this.loadNotes();
- await this.loadSharedNotePaths();
- await this.loadTemplates();
- await this.checkStatsPlugin();
- this.loadLocalSettings();
-
- // Parse URL and load specific note if provided
- this.loadItemFromURL();
-
- // Set initial homepage state ONLY if we're actually on the homepage
- if (window.location.pathname === '/') {
- window.history.replaceState({ homepageFolder: '' }, '', '/');
- document.title = this.appName;
- }
-
- // Listen for browser back/forward navigation
- window.addEventListener('popstate', (e) => {
- if (e.state && e.state.notePath) {
- // Navigating to a note
- const searchQuery = e.state.searchQuery || '';
- this.loadNote(e.state.notePath, false, searchQuery); // false = don't update history
-
- // Update search box and trigger search if needed
- if (searchQuery) {
- this.searchQuery = searchQuery;
- this.searchNotes();
- } else {
- this.searchQuery = '';
- this.searchResults = [];
- this.clearSearchHighlights();
- }
- } else if (e.state && e.state.mediaPath) {
- // Navigating to a media file
- this.viewMedia(e.state.mediaPath, null, false);
- } else {
- // Navigating back to homepage
- this.currentNote = '';
- this.noteContent = '';
- this.currentNoteName = '';
- this.outline = [];
- this.backlinks = [];
- this.shareInfo = null; // Reset share info
- document.title = this.appName;
-
- // Restore homepage folder state if it was saved
- if (e.state && e.state.homepageFolder !== undefined) {
- this.selectedHomepageFolder = e.state.homepageFolder || '';
- } else {
- // No folder state in history, go to root
- this.selectedHomepageFolder = '';
- }
-
- // Invalidate cache to force recalculation
- this._homepageCache = {
- folderPath: null,
- notes: null,
- folders: null,
- breadcrumb: null
- };
-
- // Clear search
- this.searchQuery = '';
- this.searchResults = [];
- this.clearSearchHighlights();
- }
- });
-
- // Cache DOM references after initial render
- this.$nextTick(() => {
- this.refreshDOMCache();
- });
-
- // Setup mobile view mode handler
- this.setupMobileViewMode();
-
- // Watch view mode changes and auto-save
- this.$watch('viewMode', (newValue) => {
- this.saveViewMode();
- // Scroll to top when switching modes
- this.$nextTick(() => {
- this.scrollToTop();
- });
- });
-
- // Watch for changes in note content to re-apply search highlights
- this.$watch('noteContent', () => {
- if (this.currentSearchHighlight) {
- // Re-apply highlights after content changes (with small delay for render)
- this.$nextTick(() => {
- setTimeout(() => {
- // Don't focus editor during content changes (false)
- this.highlightSearchTerm(this.currentSearchHighlight, false);
- }, 50);
- });
- }
- });
-
- // Watch tags panel expanded state and save to localStorage
- this.$watch('tagsExpanded', () => {
- this.saveTagsExpanded();
- });
-
- // Watch favorites expanded state and save to localStorage
- this.$watch('favoritesExpanded', () => {
- this.saveFavoritesExpanded();
- });
-
- // Setup keyboard shortcuts (only once to prevent double triggers)
- if (!window.__noteapp_shortcuts_initialized) {
- window.__noteapp_shortcuts_initialized = true;
- window.addEventListener('keydown', (e) => {
- // Use e.key (not e.code) for letter keys to support non-QWERTY keyboard layouts
-
- // Ctrl/Cmd + S to save
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
- e.preventDefault();
- this.saveNote();
- }
-
- // Ctrl/Cmd + Alt + P for Quick Switcher
- if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'p') {
- e.preventDefault();
- this.openQuickSwitcher();
- return;
- }
-
- // Ctrl/Cmd + Alt/Option + N for new note
- if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'n') {
- e.preventDefault();
- this.createNote();
- }
-
- // Ctrl/Cmd + Alt/Option + F for new folder
- if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'f') {
- e.preventDefault();
- this.createFolder();
- }
-
- // Ctrl/Cmd + Z for undo (without shift or alt)
- // Use e.key instead of e.code to support non-QWERTY keyboard layouts
- if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') {
- e.preventDefault();
- this.undo();
- }
-
- // Ctrl/Cmd + Y OR Ctrl/Cmd+Shift+Z for redo
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') {
- e.preventDefault();
- this.redo();
- }
- if ((e.ctrlKey || e.metaKey) && e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') {
- e.preventDefault();
- this.redo();
- }
-
- // F3 for next search match
- if (e.code === 'F3' && !e.shiftKey) {
- e.preventDefault();
- this.nextMatch();
- }
-
- // Shift + F3 for previous search match
- if (e.code === 'F3' && e.shiftKey) {
- e.preventDefault();
- this.previousMatch();
- }
-
- // Only apply markdown shortcuts when editor is focused and a note is open
- const isEditorFocused = document.activeElement?.id === 'note-editor';
- if (isEditorFocused && this.currentNote) {
- // Ctrl/Cmd + B for bold
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'b') {
- e.preventDefault();
- this.wrapSelection('**', '**', 'bold text');
- }
-
- // Ctrl/Cmd + I for italic
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'i') {
- e.preventDefault();
- this.wrapSelection('*', '*', 'italic text');
- }
-
- // Ctrl/Cmd + K for link
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
- e.preventDefault();
- this.insertLink();
- }
-
- // Ctrl/Cmd + Alt/Option + T for table
- if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 't') {
- e.preventDefault();
- this.insertTable();
- }
-
- // Ctrl/Cmd + Alt/Option + Z for Zen mode
- if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'z') {
- e.preventDefault();
- this.toggleZenMode();
- }
- }
-
- // Escape to exit Zen mode (works anywhere)
- if (e.key === 'Escape' && this.zenMode) {
- e.preventDefault();
- this.toggleZenMode();
- }
- });
- }
-
- // Note: setupScrollSync() is called when a note is loaded (see loadNote())
-
- // Listen for system theme changes
- if (window.matchMedia) {
- window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
- if (this.currentTheme === 'system') {
- this.applyTheme('system');
- }
- });
- }
-
- // Listen for fullscreen changes (to sync zen mode state)
- document.addEventListener('fullscreenchange', () => {
- if (!document.fullscreenElement && this.zenMode) {
- // User exited fullscreen manually, exit zen mode too
- this.zenMode = false;
- this.viewMode = this.previousViewMode;
- }
- });
- },
-
- // Load app configuration
- async loadConfig() {
- try {
- const response = await fetch('/api/config');
- const config = await response.json();
- this.appName = config.name;
- this.appVersion = config.version || '0.0.0';
- this.authEnabled = config.authentication?.enabled || false;
- this.demoMode = config.demoMode || false;
- this.alreadyDonated = config.alreadyDonated || false;
- } catch (error) {
- console.error('Failed to load config:', error);
- }
- },
-
- // Load available themes from backend
- async loadThemes() {
- try {
- const response = await fetch('/api/themes');
- const data = await response.json();
-
- // Use theme names directly from backend (already include emojis)
- this.availableThemes = data.themes;
- } catch (error) {
- console.error('Failed to load themes:', error);
- // Fallback to default themes
- this.availableThemes = [
- { id: 'light', name: '๐ Light' },
- { id: 'dark', name: '๐ Dark' }
- ];
- }
- },
-
- // Initialize theme system
- async initTheme() {
- // Load saved theme preference from localStorage
- const savedTheme = localStorage.getItem('noteDiscoveryTheme') || 'light';
- this.currentTheme = savedTheme;
- await this.applyTheme(savedTheme);
- },
-
- // Set and apply theme
- async setTheme(themeId) {
- this.currentTheme = themeId;
- localStorage.setItem('noteDiscoveryTheme', themeId);
- await this.applyTheme(themeId);
- },
-
- // Syntax highlighting toggle
- toggleSyntaxHighlight() {
- this.syntaxHighlightEnabled = !this.syntaxHighlightEnabled;
- localStorage.setItem('syntaxHighlightEnabled', this.syntaxHighlightEnabled);
- if (this.syntaxHighlightEnabled) {
- this.updateSyntaxHighlight();
- }
- },
-
- // Load all localStorage settings at once using centralized config
- loadLocalSettings() {
- for (const [prop, config] of Object.entries(LOCAL_SETTINGS)) {
- try {
- const saved = localStorage.getItem(config.key);
-
- if (saved === null) {
- // Use default value if not set
- this[prop] = config.default;
- } else if (config.type === 'boolean') {
- this[prop] = saved === 'true';
- } else if (config.type === 'number') {
- const num = parseFloat(saved);
- // Validate range if specified
- if (!isNaN(num) &&
- (config.min === undefined || num >= config.min) &&
- (config.max === undefined || num <= config.max)) {
- this[prop] = num;
- } else {
- this[prop] = config.default;
- }
- } else if (config.type === 'string') {
- // Validate against allowed values if specified
- if (!config.valid || config.valid.includes(saved)) {
- this[prop] = saved;
- } else {
- this[prop] = config.default;
- }
- } else if (config.type === 'json') {
- this[prop] = JSON.parse(saved);
- }
- } catch (error) {
- console.error(`Error loading setting ${prop}:`, error);
- this[prop] = config.default;
- }
- }
-
- // Special case: favorites also needs to update the Set for O(1) lookups
- this.favoritesSet = new Set(this.favorites);
- },
-
- // Readable line length toggle (for preview max-width)
- toggleReadableLineLength() {
- this.readableLineLength = !this.readableLineLength;
- localStorage.setItem('readableLineLength', this.readableLineLength);
- },
-
- // Hide underscore folders toggle (hides _attachments, _templates, etc. from sidebar)
- toggleHideUnderscoreFolders() {
- this.hideUnderscoreFolders = !this.hideUnderscoreFolders;
- localStorage.setItem('hideUnderscoreFolders', this.hideUnderscoreFolders);
- },
-
- // Tab inserts tab toggle (Tab key inserts tab character instead of changing focus)
- toggleTabInsertsTab() {
- this.tabInsertsTab = !this.tabInsertsTab;
- localStorage.setItem('tabInsertsTab', this.tabInsertsTab);
- },
-
- // Handle Tab key in editor (inserts tab if setting enabled)
- handleTabKey(event) {
- if (!this.tabInsertsTab) return;
-
- event.preventDefault();
- const textarea = event.target;
- const start = textarea.selectionStart;
- const end = textarea.selectionEnd;
- this.noteContent = this.noteContent.substring(0, start) + '\t' + this.noteContent.substring(end);
- this.$nextTick(() => {
- textarea.selectionStart = textarea.selectionEnd = start + 1;
- });
- this.autoSave();
- },
-
- // Update syntax highlight overlay (debounced, called on input)
- updateSyntaxHighlight() {
- if (!this.syntaxHighlightEnabled) return;
-
- clearTimeout(this.syntaxHighlightTimeout);
- this.syntaxHighlightTimeout = setTimeout(() => {
- const overlay = document.getElementById('syntax-overlay');
- if (overlay) {
- overlay.innerHTML = this.highlightMarkdown(this.noteContent);
- }
- }, 50); // 50ms debounce
- },
-
- // Sync overlay scroll with textarea
- syncOverlayScroll() {
- const textarea = document.getElementById('note-editor');
- const overlay = document.getElementById('syntax-overlay');
- if (textarea && overlay) {
- overlay.scrollTop = textarea.scrollTop;
- overlay.scrollLeft = textarea.scrollLeft;
- }
- },
-
- // Highlight markdown syntax
- highlightMarkdown(text) {
- if (!text) return '';
-
- // Escape HTML first
- let html = this.escapeHtml(text);
-
- // Store code blocks and inline code with placeholders to protect from other patterns
- const codePlaceholders = [];
-
- // Code blocks FIRST - protect them before anything else
- html = html.replace(/(```[\s\S]*?```)/g, (match) => {
- codePlaceholders.push('' + match + '');
- return `\x00CODE${codePlaceholders.length - 1}\x00`;
- });
-
- // Frontmatter (must be at VERY start of document, not any line)
- if (html.startsWith('---\n')) {
- html = html.replace(/^(---\n[\s\S]*?\n---)/, (match) => {
- codePlaceholders.push('' + match + '');
- return `\x00CODE${codePlaceholders.length - 1}\x00`;
- });
- }
-
- // Inline code - protect it
- html = html.replace(/`([^`\n]+)`/g, (match) => {
- codePlaceholders.push('' + match + '');
- return `\x00CODE${codePlaceholders.length - 1}\x00`;
- });
-
- // Now apply other patterns (they won't match inside protected code)
-
- // Headings - capture the whitespace to preserve exact characters (tabs vs spaces)
- // This prevents cursor/selection misalignment
- html = html.replace(/^(#{1,6})(\s)(.*)$/gm, '$1$2$3');
-
- // Bold (must come before italic)
- html = html.replace(/\*\*([^*]+)\*\*/g, '**$1**');
- html = html.replace(/__([^_]+)__/g, '__$1__');
-
- // Italic
- html = html.replace(/(?*$1*');
- html = html.replace(/(?_$1_');
-
- // Wikilinks [[...]]
- html = html.replace(/\[\[([^\]]+)\]\]/g, '[[$1]]');
-
- // Links [text](url)
- html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[$1]($2)');
-
- // Lists - use ([ \t]) to capture the space/tab and preserve exact characters
- // IMPORTANT: Don't add any characters (like \u200B) that aren't in the original,
- // as this breaks cursor/selection alignment between textarea and overlay
- html = html.replace(/^(\s*)([-*+])([ \t])(.*)$/gm, (match, indent, bullet, space, rest) => {
- return `${indent}${bullet}${space}${rest}`;
- });
- html = html.replace(/^(\s*)(\d+\.)([ \t])(.*)$/gm, (match, indent, bullet, space, rest) => {
- return `${indent}${bullet}${space}${rest}`;
- });
-
- // Blockquotes
- html = html.replace(/^(>.*)$/gm, '$1');
-
- // Horizontal rules
- html = html.replace(/^([-*_]{3,})$/gm, '$1');
-
- // Restore protected code blocks
- html = html.replace(/\x00CODE(\d+)\x00/g, (match, index) => codePlaceholders[parseInt(index)]);
-
- // Add trailing space to match textarea's phantom line for cursor
- // This ensures the overlay and textarea have the same content height
- html += '\n ';
-
- return html;
- },
-
- // Apply theme to document
- async applyTheme(themeId) {
- // Load theme CSS from file
- try {
- const response = await fetch(`/api/themes/${themeId}`);
- const data = await response.json();
-
- // Create or update style element
- let styleEl = document.getElementById('dynamic-theme');
- if (!styleEl) {
- styleEl = document.createElement('style');
- styleEl.id = 'dynamic-theme';
- document.head.appendChild(styleEl);
- }
- styleEl.textContent = data.css;
-
- // Set data attribute for theme-specific selectors
- document.documentElement.setAttribute('data-theme', themeId);
-
- // Load appropriate Highlight.js theme for code syntax highlighting
- const highlightTheme = document.getElementById('highlight-theme');
- if (highlightTheme) {
- if (themeId === 'light') {
- highlightTheme.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css';
- } else {
- // Use dark theme for dark/custom themes
- highlightTheme.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css';
- }
- }
-
- // Re-render Mermaid diagrams with new theme if there's a current note
- if (this.currentNote) {
- // Small delay to allow theme CSS to load
- setTimeout(() => {
- // Clear existing Mermaid renders
- const previewContent = document.querySelector('.markdown-preview');
- if (previewContent) {
- const mermaidContainers = previewContent.querySelectorAll('.mermaid-rendered');
- mermaidContainers.forEach(container => {
- // Replace with the original code block for re-rendering
- const parent = container.parentElement;
- if (parent && container.dataset.originalCode) {
- const pre = document.createElement('pre');
- const code = document.createElement('code');
- code.className = 'language-mermaid';
- code.textContent = container.dataset.originalCode;
- pre.appendChild(code);
- parent.replaceChild(pre, container);
- }
- });
- }
- // Re-render with new theme
- this.renderMermaid();
- }, 100);
- }
-
- // Refresh graph if visible (longer delay to ensure CSS is applied)
- if (this.showGraph) {
- setTimeout(() => this.initGraph(), 300);
- }
-
- // Update PWA theme-color meta tag to match current theme
- const themeColorMeta = document.querySelector('meta[name="theme-color"]');
- if (themeColorMeta) {
- // Get the accent color from CSS variables
- const accentColor = getComputedStyle(document.documentElement)
- .getPropertyValue('--accent-primary').trim() || '#667eea';
- themeColorMeta.setAttribute('content', accentColor);
- }
- } catch (error) {
- console.error('Failed to load theme:', error);
- }
- },
-
- // ==================== INTERNATIONALIZATION ====================
-
- // Translation function - get translated string by key
- t(key, params = {}) {
- const keys = key.split('.');
- let value = this.translations;
-
- for (const k of keys) {
- value = value?.[k];
- }
-
- // Fallback to key if translation not found (silently - default translations are inline)
- if (typeof value !== 'string') {
- return key;
- }
-
- // Replace {{param}} placeholders
- return value.replace(/\{\{(\w+)\}\}/g, (_, name) => params[name] ?? `{{${name}}}`);
- },
-
- /**
- * Get localized error message from FilenameValidator result
- * @param {object} validation - The validation result from FilenameValidator
- * @param {string} type - 'note' or 'folder'
- * @returns {string} Localized error message
- */
- getValidationErrorMessage(validation, type = 'note') {
- switch (validation.error) {
- case 'empty':
- return type === 'note'
- ? this.t('notes.empty_name')
- : this.t('folders.invalid_name');
- case 'forbidden_chars':
- return this.t('validation.forbidden_chars', {
- chars: validation.forbiddenChars
- });
- case 'reserved_name':
- return this.t('validation.reserved_name');
- case 'invalid_dot':
- return this.t('validation.invalid_dot');
- case 'trailing_dot_space':
- return this.t('validation.trailing_dot_space');
- default:
- return type === 'note'
- ? this.t('notes.invalid_name')
- : this.t('folders.invalid_name');
- }
- },
-
- // Load available locales from backend
- async loadAvailableLocales() {
- try {
- const response = await fetch('/api/locales');
- const data = await response.json();
- this.availableLocales = data.locales || [];
- } catch (error) {
- console.error('Failed to load available locales:', error);
- this.availableLocales = [{ code: 'en-US', name: 'English', flag: '๐บ๐ธ' }];
- }
- },
-
- // Load translations for a specific locale
- async loadLocale(localeCode = null) {
- const targetLocale = localeCode || localStorage.getItem('locale') || 'en-US';
-
- try {
- const response = await fetch(`/api/locales/${targetLocale}`);
- if (response.ok) {
- this.translations = await response.json();
- this.currentLocale = targetLocale;
- localStorage.setItem('locale', targetLocale);
- } else if (targetLocale !== 'en-US') {
- // Fallback to en-US if requested locale not found
- await this.loadLocale('en-US');
- }
- } catch (error) {
- console.error('Failed to load locale:', error);
- // If en-US also fails, translations will be empty and t() will return keys
- if (targetLocale !== 'en-US') {
- await this.loadLocale('en-US');
- }
- }
- },
-
- // Change locale and reload translations
- async changeLocale(localeCode) {
- await this.loadLocale(localeCode);
- },
-
- // ==================== END INTERNATIONALIZATION ====================
-
- // Load all notes
- async loadNotes() {
- try {
- const response = await fetch('/api/notes');
- const data = await response.json();
- this.notes = data.notes;
- this.allFolders = data.folders || [];
- this.buildNoteLookupMaps(); // Build O(1) lookup maps
- this.buildFolderTree();
- await this.loadTags(); // Load tags after notes are loaded
- } catch (error) {
- ErrorHandler.handle('load notes', error);
- }
- },
-
- // Build lookup maps for O(1) wikilink resolution
- buildNoteLookupMaps() {
- // Clear existing maps
- this._noteLookup.byPath.clear();
- this._noteLookup.byPathLower.clear();
- this._noteLookup.byName.clear();
- this._noteLookup.byNameLower.clear();
- this._noteLookup.byEndPath.clear();
- this._mediaLookup.clear();
-
- for (const note of this.notes) {
- const path = note.path;
- const pathLower = path.toLowerCase();
- const name = note.name;
- const nameLower = name.toLowerCase();
-
- // Handle media files separately - build media lookup map
- if (note.type !== 'note') {
- // Map filename WITH extension (case-insensitive) to full path
- // Use path to get filename with extension (note.name is stem without extension)
- const filenameWithExt = path.split('/').pop().toLowerCase();
- // First match wins if there are duplicates
- if (!this._mediaLookup.has(filenameWithExt)) {
- this._mediaLookup.set(filenameWithExt, path);
- }
- continue;
- }
-
- // Notes only from here
- const nameWithoutMd = name.replace(/\.md$/i, '');
- const nameWithoutMdLower = nameWithoutMd.toLowerCase();
-
- // Store all variations for fast lookup
- this._noteLookup.byPath.set(path, true);
- this._noteLookup.byPath.set(path.replace(/\.md$/i, ''), true);
- this._noteLookup.byPathLower.set(pathLower, true);
- this._noteLookup.byPathLower.set(pathLower.replace(/\.md$/i, ''), true);
- this._noteLookup.byName.set(name, true);
- this._noteLookup.byName.set(nameWithoutMd, true);
- this._noteLookup.byNameLower.set(nameLower, true);
- this._noteLookup.byNameLower.set(nameWithoutMdLower, true);
-
- // End path matching (for /folder/note style links)
- this._noteLookup.byEndPath.set('/' + nameWithoutMdLower, true);
- this._noteLookup.byEndPath.set('/' + nameLower, true);
- }
- },
-
- // Fast O(1) check if a wikilink target exists
- wikiLinkExists(linkTarget) {
- const targetLower = linkTarget.toLowerCase();
-
- // Check all lookup maps
- return (
- this._noteLookup.byPath.has(linkTarget) ||
- this._noteLookup.byPath.has(linkTarget + '.md') ||
- this._noteLookup.byPathLower.has(targetLower) ||
- this._noteLookup.byPathLower.has(targetLower + '.md') ||
- this._noteLookup.byName.has(linkTarget) ||
- this._noteLookup.byNameLower.has(targetLower) ||
- this._noteLookup.byEndPath.has('/' + targetLower) ||
- this._noteLookup.byEndPath.has('/' + targetLower + '.md')
- );
- },
-
- // Resolve media wikilink to full path (O(1) lookup)
- // Returns the full path if found, null otherwise
- resolveMediaWikilink(mediaName) {
- const nameLower = mediaName.toLowerCase();
- return this._mediaLookup.get(nameLower) || null;
- },
-
- // Load all tags
- async loadTags() {
- try {
- const response = await fetch('/api/tags');
- const data = await response.json();
- this.allTags = data.tags || {};
- } catch (error) {
- ErrorHandler.handle('load tags', error, false); // Don't show alert, tags are optional
- }
- },
-
- // Debounced tag reload (prevents excessive API calls during typing)
- loadTagsDebounced() {
- // Clear existing timeout
- if (this.tagReloadTimeout) {
- clearTimeout(this.tagReloadTimeout);
- }
-
- // Set new timeout - reload tags 2 seconds after last save
- this.tagReloadTimeout = setTimeout(() => {
- this.loadTags();
- }, 2000);
- },
-
- // Toggle tag selection for filtering
- toggleTag(tag) {
- const index = this.selectedTags.indexOf(tag);
- if (index > -1) {
- this.selectedTags.splice(index, 1);
- } else {
- this.selectedTags.push(tag);
- }
-
- // Apply unified filtering
- this.applyFilters();
- },
-
- // ========================================================================
- // Template Methods
- // ========================================================================
-
- // Load available templates from _templates folder
- async loadTemplates() {
- try {
- const response = await fetch('/api/templates');
- const data = await response.json();
- this.availableTemplates = data.templates || [];
- } catch (error) {
- ErrorHandler.handle('load templates', error, false); // Don't show alert, templates are optional
- }
- },
-
- // Create a new note from a template
- async createNoteFromTemplate() {
- if (!this.selectedTemplate || !this.newTemplateNoteName.trim()) {
- return;
- }
-
- try {
- // Validate the note name
- const validation = FilenameValidator.validateFilename(this.newTemplateNoteName);
- if (!validation.valid) {
- alert(this.getValidationErrorMessage(validation, 'note'));
- return;
- }
-
- // Determine the note path based on dropdown context
- let notePath = validation.sanitized;
- if (!notePath.endsWith('.md')) {
- notePath += '.md';
- }
-
- // Determine target folder: use dropdown context if set, otherwise homepage folder
- let targetFolder;
- if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) {
- targetFolder = this.dropdownTargetFolder; // Can be '' for root or a folder path
- } else {
- targetFolder = this.selectedHomepageFolder || '';
- }
-
- // If we have a target folder, create note in that folder
- if (targetFolder) {
- notePath = `${targetFolder}/${notePath}`;
- }
-
- // CRITICAL: Check if note already exists
- const existingNote = this.notes.find(note => note.path === notePath);
- if (existingNote) {
- alert(this.t('notes.already_exists', { name: validation.sanitized }));
- return;
- }
-
- // Create note from template
- const response = await fetch('/api/templates/create-note', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- templateName: this.selectedTemplate,
- notePath: notePath
- })
- });
-
- if (!response.ok) {
- const error = await response.json();
- alert(error.detail || this.t('templates.create_failed'));
- return;
- }
-
- const data = await response.json();
-
- // Close modal and reset state
- this.showTemplateModal = false;
- this.selectedTemplate = '';
- this.newTemplateNoteName = '';
-
- // Reload notes and open the new note
- await this.loadNotes();
- await this.loadNote(data.path);
- this.focusEditorForNewNote();
-
- } catch (error) {
- ErrorHandler.handle('create note from template', error);
- }
- },
-
- // Clear all tag filters
- clearTagFilters() {
- this.selectedTags = [];
-
- // Apply unified filtering
- this.applyFilters();
- },
-
- // ========================================================================
- // Outline (TOC) Methods
- // ========================================================================
-
- // Extract headings from markdown content for the outline
- extractOutline(content) {
- if (!content) {
- this.outline = [];
- this.backlinks = [];
- return;
- }
-
- const headings = [];
- const lines = content.split('\n');
- const slugCounts = {}; // Track duplicate slugs
-
- // Skip frontmatter and code blocks
- let inFrontmatter = false;
- let inCodeBlock = false;
-
- for (let i = 0; i < lines.length; i++) {
- const line = lines[i];
-
- // Handle frontmatter
- if (i === 0 && line.trim() === '---') {
- inFrontmatter = true;
- continue;
- }
- if (inFrontmatter) {
- if (line.trim() === '---') {
- inFrontmatter = false;
- }
- continue;
- }
-
- // Handle fenced code blocks (``` or ~~~)
- if (line.trim().startsWith('```') || line.trim().startsWith('~~~')) {
- inCodeBlock = !inCodeBlock;
- continue;
- }
- if (inCodeBlock) {
- continue;
- }
-
- // Match heading lines (# to ######)
- const match = line.match(/^(#{1,6})\s+(.+)$/);
- if (match) {
- const level = match[1].length;
- const text = match[2].trim();
-
- // Generate slug (GitHub-style)
- let slug = text
- .toLowerCase()
- .replace(/[^\w\s-]/g, '') // Remove special chars
- .replace(/\s+/g, '-') // Spaces to dashes
- .replace(/-+/g, '-'); // Multiple dashes to single
-
- // Handle duplicate slugs
- if (slugCounts[slug] !== undefined) {
- slugCounts[slug]++;
- slug = `${slug}-${slugCounts[slug]}`;
- } else {
- slugCounts[slug] = 0;
- }
-
- headings.push({
- level,
- text,
- slug,
- line: i + 1 // 1-indexed line number
- });
- }
- }
-
- this.outline = headings;
- },
-
- // Scroll to a heading in the editor or preview
- scrollToHeading(heading) {
- if (this.viewMode === 'preview' || this.viewMode === 'split') {
- // In preview/split mode, scroll the preview pane
- const preview = document.querySelector('.markdown-preview');
- if (preview) {
- // Find the heading element by text content (more reliable than ID)
- const headingElements = preview.querySelectorAll('h1, h2, h3, h4, h5, h6');
- for (const el of headingElements) {
- if (el.textContent.trim() === heading.text) {
- el.scrollIntoView({ behavior: 'smooth', block: 'start' });
- // Add a brief highlight effect
- el.style.transition = 'background-color 0.3s';
- el.style.backgroundColor = 'var(--accent-light)';
- setTimeout(() => {
- el.style.backgroundColor = '';
- }, 1000);
- return;
- }
- }
- }
- }
-
- if (this.viewMode === 'edit' || this.viewMode === 'split') {
- // In edit/split mode, scroll the editor to the line
- const textarea = document.querySelector('.editor-textarea');
- if (textarea && heading.line) {
- const lines = textarea.value.split('\n');
- let charPos = 0;
-
- // Calculate character position of the heading line
- for (let i = 0; i < heading.line - 1 && i < lines.length; i++) {
- charPos += lines[i].length + 1; // +1 for newline
- }
-
- // Set cursor position and scroll
- textarea.focus();
- textarea.setSelectionRange(charPos, charPos);
-
- // Calculate scroll position (approximate)
- const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight) || 24;
- const scrollTop = (heading.line - 1) * lineHeight - textarea.clientHeight / 3;
- textarea.scrollTop = Math.max(0, scrollTop);
- }
- }
- },
-
- // Navigate to a backlink (note that links to current note)
- navigateToBacklink(backlinkPath) {
- this.loadNote(backlinkPath);
- },
-
- // Unified filtering logic combining tags and text search
- async applyFilters() {
- const hasTextSearch = this.searchQuery.trim().length > 0;
- const hasTagFilter = this.selectedTags.length > 0;
-
- // Case 1: No filters at all โ show full folder tree
- if (!hasTextSearch && !hasTagFilter) {
- this.isSearching = false;
- this.searchResults = [];
- this.currentSearchHighlight = '';
- this.clearSearchHighlights();
- this.buildFolderTree();
- return;
- }
-
- // Case 2: Only tag filter โ convert to flat list of matching notes
- if (hasTagFilter && !hasTextSearch) {
- this.isSearching = false;
- this.searchResults = this.notes.filter(note =>
- note.type === 'note' && this.noteMatchesTags(note)
- );
- this.currentSearchHighlight = '';
- this.clearSearchHighlights();
- return;
- }
-
- // Case 3: Text search (with or without tag filter)
- if (hasTextSearch) {
- this.isSearching = true;
- try {
- const response = await fetch(`/api/search?q=${encodeURIComponent(this.searchQuery)}`);
- const data = await response.json();
-
- // Apply tag filtering to search results if tags are selected
- let results = data.results;
- if (hasTagFilter) {
- results = results.filter(result => {
- const note = this.notes.find(n => n.path === result.path);
- return note ? this.noteMatchesTags(note) : false;
- });
- }
-
- this.searchResults = results;
-
- // Highlight search term in current note if open
- if (this.currentNote && this.noteContent) {
- this.currentSearchHighlight = this.searchQuery;
- this.$nextTick(() => {
- this.highlightSearchTerm(this.searchQuery, false);
- });
- }
- } catch (error) {
- console.error('Search failed:', error);
- this.searchResults = [];
- } finally {
- this.isSearching = false;
- }
- }
- },
-
- // Check if a note matches selected tags (AND logic)
- noteMatchesTags(note) {
- if (this.selectedTags.length === 0) {
- return true; // No filter active
- }
- if (!note.tags || note.tags.length === 0) {
- return false; // Note has no tags but filter is active
- }
- // Check if note has ALL selected tags (AND logic)
- return this.selectedTags.every(tag => note.tags.includes(tag));
- },
-
- // Get all tags sorted by name
- get sortedTags() {
- return Object.entries(this.allTags).sort((a, b) => a[0].localeCompare(b[0]));
- },
-
- // Get tags for current note
- get currentNoteTags() {
- if (!this.currentNote) return [];
- const note = this.notes.find(n => n.path === this.currentNote);
- return note && note.tags ? note.tags : [];
- },
-
- // ==================== FAVORITES ====================
-
- // Save favorites to localStorage
- saveFavorites() {
- try {
- localStorage.setItem('noteFavorites', JSON.stringify(this.favorites));
- } catch (e) {
- console.warn('Could not save favorites to localStorage');
- }
- },
-
- // Check if a note is favorited (O(1) lookup)
- isFavorite(notePath) {
- return this.favoritesSet.has(notePath);
- },
-
- // Toggle favorite status for a note
- toggleFavorite(notePath = null) {
- const path = notePath || this.currentNote;
- if (!path) return;
-
- if (this.favoritesSet.has(path)) {
- // Remove from favorites
- this.favorites = this.favorites.filter(f => f !== path);
- } else {
- // Add to favorites
- this.favorites = [...this.favorites, path];
- }
- // Recreate Set from array for consistency
- this.favoritesSet = new Set(this.favorites);
- this.saveFavorites();
- },
-
- // Get favorite notes with full details (for display)
- get favoriteNotes() {
- return this.favorites
- .map(path => {
- // Find note by exact path or case-insensitive match
- let note = this.notes.find(n => n.path === path);
- if (!note) {
- note = this.notes.find(n => n.path.toLowerCase() === path.toLowerCase());
- }
- if (!note) return null;
- return {
- path: note.path, // Use actual path from notes (fixes case issues)
- name: note.path.split('/').pop().replace('.md', ''),
- folder: note.folder || ''
- };
- })
- .filter(Boolean); // Remove nulls (deleted notes)
- },
-
- saveFavoritesExpanded() {
- try {
- localStorage.setItem('favoritesExpanded', this.favoritesExpanded.toString());
- } catch (e) {
- console.error('Error saving favorites expanded state:', e);
- }
- },
-
- // Get current note's last modified time as relative string
- get lastEditedText() {
- if (!this.currentNote) return '';
- const note = this.notes.find(n => n.path === this.currentNote);
- if (!note || !note.modified) return '';
-
- const modified = new Date(note.modified);
- const now = new Date();
- const diffMs = now - modified;
- const diffSecs = Math.floor(diffMs / 1000);
- const diffMins = Math.floor(diffSecs / 60);
- const diffHours = Math.floor(diffMins / 60);
- const diffDays = Math.floor(diffHours / 24);
-
- if (diffSecs < 60) return this.t('editor.just_now');
- if (diffMins < 60) return this.t('editor.minutes_ago', { count: diffMins });
- if (diffHours < 24) return this.t('editor.hours_ago', { count: diffHours });
- if (diffDays < 7) return this.t('editor.days_ago', { count: diffDays });
-
- // For older dates, show the date in selected locale
- return modified.toLocaleDateString(this.currentLocale, { month: 'short', day: 'numeric' });
- },
-
- // Parse tags from markdown content (matches backend logic)
- parseTagsFromContent(content) {
- if (!content || !content.trim().startsWith('---')) {
- return [];
- }
-
- try {
- const lines = content.split('\n');
- if (lines[0].trim() !== '---') return [];
-
- // Find closing ---
- let endIdx = -1;
- for (let i = 1; i < lines.length; i++) {
- if (lines[i].trim() === '---') {
- endIdx = i;
- break;
- }
- }
-
- if (endIdx === -1) return [];
-
- const frontmatterLines = lines.slice(1, endIdx);
- const tags = [];
- let inTagsList = false;
-
- for (const line of frontmatterLines) {
- const stripped = line.trim();
-
- // Check for inline array: tags: [tag1, tag2]
- if (stripped.startsWith('tags:')) {
- const rest = stripped.substring(5).trim();
- if (rest.startsWith('[') && rest.endsWith(']')) {
- const tagsStr = rest.substring(1, rest.length - 1);
- const rawTags = tagsStr.split(',').map(t => t.trim());
- tags.push(...rawTags.filter(t => t).map(t => t.toLowerCase()));
- break;
- } else if (rest) {
- tags.push(rest.toLowerCase());
- break;
- } else {
- inTagsList = true;
- }
- } else if (inTagsList) {
- if (stripped.startsWith('-')) {
- const tag = stripped.substring(1).trim();
- if (tag && !tag.startsWith('#')) {
- tags.push(tag.toLowerCase());
- }
- } else if (stripped && !stripped.startsWith('#')) {
- break;
- }
- }
- }
-
- return [...new Set(tags)].sort();
- } catch (e) {
- console.error('Error parsing tags:', e);
- return [];
- }
- },
-
- // Build folder tree structure
- buildFolderTree() {
- const tree = {};
-
- // Add ALL folders from backend (including empty ones)
- this.allFolders.forEach(folderPath => {
- const parts = folderPath.split('/');
- let current = tree;
-
- parts.forEach((part, index) => {
- const fullPath = parts.slice(0, index + 1).join('/');
-
- if (!current[part]) {
- current[part] = {
- name: part,
- path: fullPath,
- children: {},
- notes: []
- };
- }
- current = current[part].children;
- });
- });
-
- // Add ALL notes to their folders (no filtering - tree only shown when no filters active)
- this.notes.forEach(note => {
- if (!note.folder) {
- // Root level note
- if (!tree['__root__']) {
- tree['__root__'] = {
- name: '',
- path: '',
- children: {},
- notes: []
- };
- }
- tree['__root__'].notes.push(note);
- } else {
- // Navigate to the folder and add note
- const parts = note.folder.split('/');
- let current = tree;
-
- for (let i = 0; i < parts.length; i++) {
- if (!current[parts[i]]) {
- current[parts[i]] = {
- name: parts[i],
- path: parts.slice(0, i + 1).join('/'),
- children: {},
- notes: []
- };
- }
- if (i === parts.length - 1) {
- current[parts[i]].notes.push(note);
- } else {
- current = current[parts[i]].children;
- }
- }
- }
- });
-
- // Sort all notes arrays alphabetically (create new sorted arrays for reactivity)
- const sortNotes = (obj) => {
- if (obj.notes && obj.notes.length > 0) {
- // Create a new sorted array instead of mutating for Alpine reactivity
- obj.notes = [...obj.notes].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
- }
- if (obj.children && Object.keys(obj.children).length > 0) {
- Object.values(obj.children).forEach(child => sortNotes(child));
- }
- };
-
- // Sort notes in root (create new array for reactivity)
- if (tree['__root__'] && tree['__root__'].notes) {
- tree['__root__'].notes = [...tree['__root__'].notes].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
- }
-
- // Sort notes in all folders
- Object.values(tree).forEach(folder => {
- if (folder.path !== undefined) { // Skip __root__ as it was already sorted
- sortNotes(folder);
- }
- });
-
- // Calculate and cache note counts recursively (for performance)
- const calculateNoteCounts = (folderNode) => {
- const directNotes = folderNode.notes ? folderNode.notes.length : 0;
-
- if (!folderNode.children || Object.keys(folderNode.children).length === 0) {
- folderNode.noteCount = directNotes;
- return directNotes;
- }
-
- const childNotesCount = Object.values(folderNode.children).reduce(
- (total, child) => total + calculateNoteCounts(child),
- 0
- );
-
- folderNode.noteCount = directNotes + childNotesCount;
- return folderNode.noteCount;
- };
-
- // Calculate note counts for all folders
- Object.values(tree).forEach(folder => {
- if (folder.path !== undefined || folder === tree['__root__']) {
- calculateNoteCounts(folder);
- }
- });
-
- // Invalidate homepage cache when tree is rebuilt
- this._homepageCache = {
- folderPath: null,
- notes: null,
- folders: null,
- breadcrumb: null
- };
-
- // Assign new tree (Alpine will detect the change)
- this.folderTree = tree;
- },
-
- // =====================================================================
- // DATA-ATTRIBUTE BASED HANDLERS
- // These read path/name/type from data-* attributes, avoiding JS escaping issues
- // =====================================================================
-
- // Escape strings for HTML attributes (simpler than JS escaping)
- escapeHtmlAttr(str) {
- if (!str) return '';
- return str
- .replace(/&/g, '&')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
- .replace(//g, '>');
- },
-
- // Folder handlers - read from dataset
- handleFolderClick(el) {
- this.toggleFolder(el.dataset.path);
- },
- handleFolderDragOver(el, event) {
- event.preventDefault();
- this.dragOverFolder = el.dataset.path;
- el.classList.add('drag-over');
- },
- handleFolderDragLeave(el) {
- this.dragOverFolder = null;
- el.classList.remove('drag-over');
- },
- handleFolderDrop(el, event) {
- event.stopPropagation();
- el.classList.remove('drag-over');
- this.onFolderDrop(el.dataset.path);
- },
- handleNewItemClick(el, event) {
- event.stopPropagation();
- this.dropdownTargetFolder = el.dataset.path;
- this.toggleNewDropdown(event);
- },
- handleRenameFolderClick(el, event) {
- event.stopPropagation();
- this.renameFolder(el.dataset.path, el.dataset.name);
- },
- handleDeleteFolderClick(el, event) {
- event.stopPropagation();
- this.deleteFolder(el.dataset.path, el.dataset.name);
- },
-
- // Item (note/media) handlers - read from dataset
- handleItemClick(el) {
- this.openItem(el.dataset.path, el.dataset.type);
- },
- handleItemHover(el, isEnter) {
- const path = el.dataset.path;
- if (path !== this.currentNote && path !== this.currentMedia) {
- el.style.backgroundColor = isEnter ? 'var(--bg-hover)' : 'transparent';
- }
- },
- handleDeleteItemClick(el, event) {
- event.stopPropagation();
- if (el.dataset.type === 'image') {
- this.deleteMedia(el.dataset.path);
- } else {
- this.deleteNote(el.dataset.path, el.dataset.name);
- }
- },
-
- // =====================================================================
- // FOLDER TREE RENDERING
- // =====================================================================
-
- // Render folder recursively (helper for deep nesting)
- // Uses data-* attributes to store path/name, avoiding JS string escaping issues
- renderFolderRecursive(folder, level = 0, isTopLevel = false) {
- if (!folder) return '';
-
- let html = '';
- const isExpanded = this.expandedFolders.has(folder.path);
- const esc = (s) => this.escapeHtmlAttr(s); // Shorthand for HTML escaping
-
- // Render this folder's header
- // Note: Using native event handlers with data-* attributes instead of Alpine directives
- // because x-html doesn't process Alpine directives in dynamically generated content
- html += `
-
-
-
-
-
- ${esc(folder.name)}
- ${folder.notes.length === 0 && (!folder.children || Object.keys(folder.children).length === 0) ? `(${this.t('folders.empty')})` : ''}
-
-
-
-
- `;
-
- // If expanded, render folder contents (child folders + notes)
- if (isExpanded) {
- html += `
`;
-
- // First, render child folders (if any)
- if (folder.children && Object.keys(folder.children).length > 0) {
- const children = Object.entries(folder.children)
- .filter(([k, v]) => !this.hideUnderscoreFolders || !v.name.startsWith('_'))
- .sort((a, b) => a[1].name.toLowerCase().localeCompare(b[1].name.toLowerCase()));
-
- children.forEach(([childKey, childFolder]) => {
- html += this.renderFolderRecursive(childFolder, 0, false);
- });
- }
-
- // Then, render notes and images in this folder (after subfolders)
- if (folder.notes && folder.notes.length > 0) {
- folder.notes.forEach(note => {
- html += this.renderNoteItem(note);
- });
- }
-
- html += `
`; // Close folder-contents
- }
-
- html += `
`; // Close folder wrapper
- return html;
- },
-
- // Render a single note/media item (used by both folders and root level)
- renderNoteItem(note) {
- const esc = (s) => this.escapeHtmlAttr(s);
- const isMediaFile = note.type !== 'note';
- const isCurrentNote = this.currentNote === note.path;
- const isCurrentMedia = this.currentMedia === note.path;
- const isCurrent = isMediaFile ? isCurrentMedia : isCurrentNote;
-
- // Share icon for shared notes
- const isShared = !isMediaFile && this.isNoteShared(note.path);
- const shareIcon = isShared ? '' : '';
- const icon = this.getMediaIcon(note.type);
-
- return `
-
-
${shareIcon}${icon}${icon ? ' ' : ''}${esc(note.name)}
-
-
- `;
- },
-
- // Render root-level items (notes and media not in any folder)
- renderRootItems() {
- const root = this.folderTree['__root__'];
- if (!root || !root.notes || root.notes.length === 0) {
- return '';
- }
- return root.notes.map(note => this.renderNoteItem(note)).join('');
- },
-
- // Toggle folder expansion
- toggleFolder(folderPath) {
- if (this.expandedFolders.has(folderPath)) {
- this.expandedFolders.delete(folderPath);
- } else {
- this.expandedFolders.add(folderPath);
- }
- // Force Alpine reactivity by creating new Set reference
- this.expandedFolders = new Set(this.expandedFolders);
- },
-
- // Check if folder is expanded
- isFolderExpanded(folderPath) {
- return this.expandedFolders.has(folderPath);
- },
-
- // Expand all folders
- expandAllFolders() {
- this.allFolders.forEach(folder => {
- this.expandedFolders.add(folder);
- });
- // Force Alpine reactivity
- this.expandedFolders = new Set(this.expandedFolders);
- },
-
- // Collapse all folders
- collapseAllFolders() {
- this.expandedFolders.clear();
- // Force Alpine reactivity
- this.expandedFolders = new Set(this.expandedFolders);
- },
-
- // Expand folder tree to show a specific note
- expandFolderForNote(notePath) {
- const parts = notePath.split('/');
-
- // If note is in root, no folders to expand
- if (parts.length <= 1) return;
-
- // Remove the note name (last part)
- parts.pop();
-
- // Build and expand all parent folders
- let currentPath = '';
- parts.forEach((part, index) => {
- currentPath = index === 0 ? part : `${currentPath}/${part}`;
- this.expandedFolders.add(currentPath);
- });
-
- // Force Alpine reactivity
- this.expandedFolders = new Set(this.expandedFolders);
- },
-
- // Scroll note into view in the sidebar navigation
- scrollNoteIntoView(notePath) {
- // Find the note element in the sidebar
- // Use a slight delay to ensure DOM is fully rendered with Alpine bindings applied
- setTimeout(() => {
- const sidebar = document.querySelector('.flex-1.overflow-y-auto.custom-scrollbar');
- if (!sidebar) return;
-
- const noteElements = sidebar.querySelectorAll('.note-item');
- let targetElement = null;
- const noteName = notePath.split('/').pop().replace('.md', '');
-
- // Find the element that corresponds to this note
- noteElements.forEach(el => {
- // Check if this is a note element (not folder) by checking if it has the note name
- if (el.textContent.trim().startsWith(noteName) || el.textContent.includes(noteName)) {
- // Check computed style to see if it's highlighted
- const computedStyle = window.getComputedStyle(el);
- const bgColor = computedStyle.backgroundColor;
-
- // Check if background has the accent color (not transparent or default)
- if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent' && !bgColor.includes('255, 255, 255')) {
- targetElement = el;
- }
- }
- });
-
- // If found, scroll it into view
- if (targetElement) {
- targetElement.scrollIntoView({
- behavior: 'smooth',
- block: 'center',
- inline: 'nearest'
- });
- }
- }, 200); // Increased delay to ensure Alpine has finished rendering
- },
-
- // Unified drag and drop handlers for notes, folders, and media
- onItemDragStart(itemPath, itemType, event) {
- // Set unified drag state
- this.draggedItem = { path: itemPath, type: itemType };
-
- // Make drag image semi-transparent
- if (event.target) {
- event.target.style.opacity = '0.5';
- }
-
- event.dataTransfer.effectAllowed = 'all';
- },
-
- onItemDragEnd() {
- this.draggedItem = null;
- this.dropTarget = null;
- this.dragOverFolder = null;
- // Reset opacity of all draggable items
- document.querySelectorAll('.note-item, .folder-header').forEach(el => el.style.opacity = '1');
- // Reset drag-over class
- document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
- },
-
-
- // Handle dragover on editor to show cursor position
- onEditorDragOver(event) {
- if (!this.draggedItem) return;
-
- event.preventDefault();
- this.dropTarget = 'editor';
-
- // Focus the textarea
- const textarea = event.target;
- if (textarea.tagName !== 'TEXTAREA') return;
-
- textarea.focus();
-
- // Calculate cursor position from mouse coordinates
- const pos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY);
- if (pos >= 0) {
- textarea.setSelectionRange(pos, pos);
- }
- },
-
- // Calculate textarea cursor position from mouse coordinates
- getTextareaCursorFromPoint(textarea, x, y) {
- const rect = textarea.getBoundingClientRect();
- const style = window.getComputedStyle(textarea);
- const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2;
- const paddingTop = parseFloat(style.paddingTop) || 0;
- const paddingLeft = parseFloat(style.paddingLeft) || 0;
-
- // Calculate which line we're on
- const relativeY = y - rect.top - paddingTop + textarea.scrollTop;
- const lineIndex = Math.max(0, Math.floor(relativeY / lineHeight));
-
- // Split content into lines
- const lines = textarea.value.split('\n');
-
- // Find the character position at the start of this line
- let charPos = 0;
- for (let i = 0; i < Math.min(lineIndex, lines.length); i++) {
- charPos += lines[i].length + 1; // +1 for newline
- }
-
- // If we're beyond the last line, position at end
- if (lineIndex >= lines.length) {
- return textarea.value.length;
- }
-
- // Approximate character position within the line based on X coordinate
- const relativeX = x - rect.left - paddingLeft;
- const charWidth = parseFloat(style.fontSize) * 0.6; // Approximate for monospace
- const charInLine = Math.max(0, Math.floor(relativeX / charWidth));
- const lineLength = lines[lineIndex]?.length || 0;
-
- return charPos + Math.min(charInLine, lineLength);
- },
-
- // Handle dragenter on editor
- onEditorDragEnter(event) {
- if (!this.draggedItem) return;
- event.preventDefault();
- this.dropTarget = 'editor';
- },
-
- // Handle dragleave on editor
- onEditorDragLeave(event) {
- // Only clear dropTarget if we're actually leaving the editor
- // (not just moving between child elements)
- if (event.target.tagName === 'TEXTAREA') {
- this.dropTarget = null;
- }
- },
-
- // Handle drop into editor to create internal link or upload media
- async onEditorDrop(event) {
- event.preventDefault();
- this.dropTarget = null;
-
- // Check if files are being dropped (media from file system)
- if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
- await this.handleMediaDrop(event);
- return;
- }
-
- // Otherwise, handle note/media link drop from sidebar
- if (!this.draggedItem) return;
-
- const notePath = this.draggedItem.path;
- const isMediaFile = this.draggedItem.type !== 'note';
-
- let link;
- if (isMediaFile) {
- // For media files (images, audio, video, PDF), use wiki-style embed link
- const filename = notePath.split('/').pop();
- link = `![[${filename}]]`;
- } else {
- // For notes, insert note link
- const noteName = notePath.split('/').pop().replace('.md', '');
- const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
- link = `[${noteName}](${encodedPath})`;
- }
-
- // Insert at drop position
- const textarea = event.target;
- // Recalculate position from drop coordinates for accuracy
- let cursorPos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY);
- if (cursorPos < 0) cursorPos = textarea.selectionStart || 0;
- const textBefore = this.noteContent.substring(0, cursorPos);
- const textAfter = this.noteContent.substring(cursorPos);
-
- this.noteContent = textBefore + link + textAfter;
-
- // Move cursor after the link
- this.$nextTick(() => {
- textarea.selectionStart = textarea.selectionEnd = cursorPos + link.length;
- textarea.focus();
- });
-
- // Trigger autosave
- this.autoSave();
-
- this.draggedItem = null;
- },
-
- // Handle media files dropped into editor
- async handleMediaDrop(event) {
- if (!this.currentNote) {
- alert(this.t('notes.open_first'));
- return;
- }
-
- const files = Array.from(event.dataTransfer.files);
-
- // Filter for allowed media types
- const allowedTypes = [
- // Images
- 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
- // Audio
- 'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/x-m4a',
- // Video
- 'video/mp4', 'video/webm', 'video/quicktime',
- // Documents
- 'application/pdf'
- ];
- const mediaFiles = files.filter(file => allowedTypes.includes(file.type.toLowerCase()));
-
- if (mediaFiles.length === 0) {
- alert(this.t('media.no_valid_files'));
- return;
- }
-
- const textarea = event.target;
- // Calculate cursor position from drop coordinates
- let cursorPos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY);
- if (cursorPos < 0) cursorPos = textarea.selectionStart || 0;
-
- // Upload each media file
- for (const file of mediaFiles) {
- try {
- const mediaPath = await this.uploadMedia(file, this.currentNote);
- if (mediaPath) {
- await this.insertMediaMarkdown(mediaPath, file.name, cursorPos);
- }
- } catch (error) {
- ErrorHandler.handle(`upload file ${file.name}`, error);
- }
- }
- },
-
- // Upload a media file (image, audio, video, PDF)
- async uploadMedia(file, notePath) {
- const formData = new FormData();
- formData.append('file', file);
- formData.append('note_path', notePath);
-
- try {
- const response = await fetch('/api/upload-media', {
- method: 'POST',
- body: formData
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.detail || 'Upload failed');
- }
-
- const data = await response.json();
- return data.path;
- } catch (error) {
- throw error;
- }
- },
-
- // Insert media markdown at cursor position using wiki-style syntax
- // This ensures media links don't break when notes are moved
- 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();
-
- // 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 altWithoutExt = altText.replace(/\.[^/.]+$/, '');
-
- // If alt text is meaningful (not just "pasted-image"), include it
- const markdown = (altWithoutExt && altWithoutExt !== filenameWithoutExt && !altWithoutExt.startsWith('pasted-image'))
- ? `![[${filename}|${altWithoutExt}]]`
- : `![[${filename}]]`;
-
- // Reload notes FIRST to update image lookup maps before preview renders
- await this.loadNotes();
-
- const textBefore = this.noteContent.substring(0, cursorPos);
- const textAfter = this.noteContent.substring(cursorPos);
-
- this.noteContent = textBefore + markdown + '\n' + textAfter;
-
- // Trigger autosave
- this.autoSave();
- },
-
- // Handle paste event for clipboard media (images)
- async handlePaste(event) {
- if (!this.currentNote) return;
-
- const items = event.clipboardData?.items;
- if (!items) return;
-
- for (const item of items) {
- if (item.type.startsWith('image/')) {
- event.preventDefault();
-
- const blob = item.getAsFile();
- if (blob) {
- try {
- const textarea = event.target;
- const cursorPos = textarea.selectionStart || 0;
-
- // Create a simple filename - backend will add timestamp to prevent collisions
- const ext = item.type.split('/')[1] || 'png';
- const filename = `pasted-image.${ext}`;
-
- // Create a File from the blob
- const file = new File([blob], filename, { type: item.type });
-
- const mediaPath = await this.uploadMedia(file, this.currentNote);
- if (mediaPath) {
- await this.insertMediaMarkdown(mediaPath, filename, cursorPos);
- }
- } catch (error) {
- ErrorHandler.handle('paste media', error);
- }
- }
- break; // Only handle first media item
- }
- }
- },
-
- // Media type detection based on file extension
- getMediaType(filename) {
- if (!filename) return null;
- const ext = filename.split('.').pop().toLowerCase();
- const mediaTypes = {
- image: ['jpg', 'jpeg', 'png', 'gif', 'webp'],
- audio: ['mp3', 'wav', 'ogg', 'm4a'],
- video: ['mp4', 'webm', 'mov', 'avi'],
- document: ['pdf'],
- };
- for (const [type, extensions] of Object.entries(mediaTypes)) {
- if (extensions.includes(ext)) return type;
- }
- return null;
- },
-
- // Get icon for media type
- getMediaIcon(type) {
- const icons = {
- image: '๐ผ๏ธ',
- audio: '๐ต',
- video: '๐ฌ',
- document: '๐',
- };
- return icons[type] || '';
- },
-
- // Open a note or media file (unified handler for sidebar/homepage clicks)
- openItem(path, type = 'note', searchHighlight = '') {
- this.showGraph = false;
- // Check if it's a media file by type or extension
- const mediaType = type !== 'note' ? type : this.getMediaType(path);
- if (mediaType && mediaType !== 'note') {
- this.viewMedia(path, mediaType);
- } else {
- this.loadNote(path, true, searchHighlight);
- }
- },
-
- // View a media file (image, audio, video, PDF) in the main pane
- viewMedia(mediaPath, mediaType = null, updateHistory = true) {
- this.showGraph = false; // Ensure graph is closed
- this.currentNote = '';
- this.currentNoteName = '';
- this.noteContent = '';
- this.currentMedia = mediaPath; // Reuse currentMedia for all media
- this.currentMediaType = mediaType || this.getMediaType(mediaPath) || 'image';
- this.shareInfo = null; // Reset share info
- this.viewMode = 'preview'; // Use preview mode to show media
-
- // Update browser tab title
- const fileName = mediaPath.split('/').pop();
- document.title = `${fileName} - ${this.appName}`;
-
- // Expand folder tree to show the media file
- this.expandFolderForNote(mediaPath);
-
- // Update browser URL
- if (updateHistory) {
- // Encode each path segment to handle special characters
- const encodedPath = mediaPath.split('/').map(segment => encodeURIComponent(segment)).join('/');
- window.history.pushState(
- { mediaPath: mediaPath },
- '',
- `/${encodedPath}`
- );
- }
- },
-
- // Backward compatibility alias
- viewImage(mediaPath, updateHistory = true) {
- this.viewMedia(mediaPath, 'image', updateHistory);
- },
-
- // Delete a media file (image, audio, video, PDF)
- async deleteMedia(mediaPath) {
- const filename = mediaPath.split('/').pop();
- if (!confirm(this.t('media.confirm_delete', { name: filename }))) return;
-
- try {
- const response = await fetch(`/api/notes/${encodeURIComponent(mediaPath)}`, {
- method: 'DELETE'
- });
-
- 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) {
- ErrorHandler.handle('delete media', error);
- }
- },
-
- // Handle clicks on internal links in preview
- handleInternalLink(event) {
- // Check if clicked element is a link
- const link = event.target.closest('a');
- if (!link) return;
-
- const href = link.getAttribute('href');
- if (!href) return;
-
- // Check if it's an external link or API path (media files, etc.)
- if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//') || href.startsWith('mailto:') || href.startsWith('/api/')) {
- return; // Let external links and API paths work normally
- }
-
- // Prevent default navigation for internal links
- event.preventDefault();
-
- // Parse href into note path and anchor (e.g., "note.md#section" -> notePath="note.md", anchor="section")
- const decodedHref = decodeURIComponent(href);
- const hashIndex = decodedHref.indexOf('#');
- const notePath = hashIndex !== -1 ? decodedHref.substring(0, hashIndex) : decodedHref;
- const anchor = hashIndex !== -1 ? decodedHref.substring(hashIndex + 1) : null;
-
- // If it's just an anchor link (#heading), scroll within current note
- if (!notePath && anchor) {
- this.scrollToAnchor(anchor);
- return;
- }
-
- // Skip if no path
- if (!notePath) return;
-
- // Find the note by path (try exact match first, then with .md extension)
- let targetNote = this.notes.find(n =>
- n.path === notePath ||
- n.path === notePath + '.md'
- );
-
- if (!targetNote) {
- // Try to find by name (in case link uses just the note name without path)
- targetNote = this.notes.find(n =>
- n.name === notePath ||
- n.name === notePath + '.md' ||
- n.name.toLowerCase() === notePath.toLowerCase() ||
- n.name.toLowerCase() === (notePath + '.md').toLowerCase()
- );
- }
-
- if (!targetNote) {
- // Last resort: case-insensitive path matching
- targetNote = this.notes.find(n =>
- n.path.toLowerCase() === notePath.toLowerCase() ||
- n.path.toLowerCase() === (notePath + '.md').toLowerCase()
- );
- }
-
- if (targetNote) {
- // Load the note, then scroll to anchor if present
- this.loadNote(targetNote.path).then(() => {
- if (anchor) {
- // Small delay to ensure content is rendered
- setTimeout(() => this.scrollToAnchor(anchor), 100);
- }
- });
- } else if (confirm(this.t('notes.create_from_link', { path: notePath }))) {
- // Note doesn't exist - create it (reuses createNote with duplicate check)
- this.createNote(null, notePath);
- }
- },
-
- // Scroll to an anchor (heading) by slug - reuses outline data
- scrollToAnchor(anchor) {
- // Normalize the anchor (GitHub-style slug)
- const targetSlug = anchor
- .toLowerCase()
- .replace(/[^\w\s-]/g, '')
- .replace(/\s+/g, '-')
- .replace(/-+/g, '-');
-
- // Find matching heading in outline
- const heading = this.outline.find(h => h.slug === targetSlug);
-
- if (heading) {
- this.scrollToHeading(heading);
- } else {
- // Fallback: try to find heading by exact text match
- const headingByText = this.outline.find(h =>
- h.text.toLowerCase().replace(/\s+/g, '-') === anchor.toLowerCase()
- );
- if (headingByText) {
- this.scrollToHeading(headingByText);
- }
- }
- },
-
-
- cancelDrag() {
- // Cancel any active drag operation (triggered by ESC key)
- this.draggedItem = null;
- this.dropTarget = null;
- this.dragOverFolder = null;
- // Reset styles - only query elements with drag-over class (more efficient)
- document.querySelectorAll('.folder-item').forEach(el => el.style.opacity = '1');
- document.querySelectorAll('.note-item').forEach(el => el.style.opacity = '1');
- document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
- },
-
- async onFolderDrop(targetFolderPath) {
- // Ignore if we're dropping into the editor
- if (this.dropTarget === 'editor') {
- return;
- }
-
- // Capture dragged item info immediately (ondragend may clear it)
- if (!this.draggedItem) return;
- const { path: draggedPath, type: draggedType } = this.draggedItem;
-
- // Determine item category for endpoint selection
- const isFolder = draggedType === 'folder';
- const isNote = draggedType === 'note';
- const isMedia = !isFolder && !isNote; // image, audio, video, document
-
- // Handle folder drop
- if (isFolder) {
- // Prevent dropping folder into itself or its subfolders
- if (targetFolderPath === draggedPath ||
- targetFolderPath.startsWith(draggedPath + '/')) {
- alert(this.t('folders.cannot_move_into_self'));
- return;
- }
-
- const folderName = draggedPath.split('/').pop();
- const newPath = targetFolderPath ? `${targetFolderPath}/${folderName}` : folderName;
-
- if (newPath === draggedPath) return;
-
- // Capture favorites info before async call
- const oldPrefix = draggedPath + '/';
- const newPrefix = newPath + '/';
-
- try {
- const response = await fetch('/api/folders/move', {
- 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));
- if (favoritesInFolder.length > 0) {
- const newFavorites = this.favorites.map(f =>
- f.startsWith(oldPrefix) ? newPrefix + f.substring(oldPrefix.length) : f
- );
- this.favorites = newFavorites;
- this.favoritesSet = new Set(newFavorites);
- this.saveFavorites();
- }
-
- // Keep folder expanded if it was
- const wasExpanded = this.expandedFolders.has(draggedPath);
-
- await this.loadNotes();
- await this.loadSharedNotePaths();
-
- if (wasExpanded) {
- this.expandedFolders.delete(draggedPath);
- this.expandedFolders.add(newPath);
- this.saveExpandedFolders();
- }
- } else {
- const errorData = await response.json().catch(() => ({}));
- alert(errorData.detail || this.t('move.failed_folder'));
- }
- } catch (error) {
- console.error('Failed to move folder:', error);
- alert(this.t('move.failed_folder'));
- }
- return;
- }
-
- // Handle note or media drop into folder
- const item = this.notes.find(n => n.path === draggedPath);
- if (!item) return;
-
- const filename = draggedPath.split('/').pop();
- const newPath = targetFolderPath ? `${targetFolderPath}/${filename}` : filename;
-
- if (newPath === draggedPath) return;
-
- // Check if note is favorited (only for notes)
- const wasFavorited = isNote && this.favoritesSet.has(draggedPath);
-
- try {
- // Use different endpoint for media vs notes
- const endpoint = isMedia ? '/api/media/move' : '/api/notes/move';
- const response = await fetch(endpoint, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ oldPath: draggedPath, newPath })
- });
-
- 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 errorKey = isMedia ? 'move.failed_media' : 'move.failed_note';
- alert(errorData.detail || this.t(errorKey));
- }
- } catch (error) {
- console.error(`Failed to move ${isMedia ? 'media' : 'note'}:`, error);
- const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note';
- alert(this.t(errorKey));
- }
- },
-
-
- // Load a specific note
- async loadNote(notePath, updateHistory = true, searchQuery = '') {
- try {
- // Close mobile sidebar when a note is selected
- this.mobileSidebarOpen = false;
-
- const response = await fetch(`/api/notes/${notePath}`);
-
- // Check if note exists
- if (!response.ok) {
- if (response.status === 404) {
- // Note not found - silently redirect to home
- window.history.replaceState({ homepageFolder: this.selectedHomepageFolder || '' }, '', '/');
- this.currentNote = '';
- this.noteContent = '';
- this.currentMedia = '';
- document.title = this.appName;
- return;
- }
- throw new Error(`HTTP error! status: ${response.status}`);
- }
-
- const data = await response.json();
-
- this.currentNote = notePath;
- this._lastRenderedContent = ''; // Clear render cache for new note
- this._cachedRenderedHTML = '';
- this._initializedVideoSources = new Set(); // Clear video cache for new note
- this.noteContent = data.content;
- this.currentNoteName = notePath.split('/').pop().replace('.md', '');
- this.currentMedia = ''; // Clear image viewer when loading a note
- this.shareInfo = null; // Reset share info for new note
-
- // Update browser tab title
- document.title = `${this.currentNoteName} - ${this.appName}`;
- this.lastSaved = false;
-
- // Extract outline for TOC panel
- this.extractOutline(data.content);
-
- // Store backlinks from API response
- this.backlinks = data.backlinks || [];
-
- // Initialize undo/redo history for this note (with cursor at start)
- this.undoHistory = [{ content: data.content, cursorPos: 0 }];
- this.redoHistory = [];
- this.hasPendingHistoryChanges = false;
-
- // Update browser URL and history
- if (updateHistory) {
- // Encode the path properly (spaces become %20, etc.)
- const pathWithoutExtension = notePath.replace('.md', '');
- // Encode each path segment to handle special characters
- const encodedPath = pathWithoutExtension.split('/').map(segment => encodeURIComponent(segment)).join('/');
- let url = `/${encodedPath}`;
- // Add search query parameter if present
- if (searchQuery) {
- url += `?search=${encodeURIComponent(searchQuery)}`;
- }
- window.history.pushState(
- {
- notePath: notePath,
- searchQuery: searchQuery,
- homepageFolder: this.selectedHomepageFolder || '' // Save current folder state
- },
- '',
- url
- );
- }
-
- // Calculate stats if plugin enabled
- if (this.statsPluginEnabled) {
- this.calculateStats();
- }
-
- // Parse frontmatter metadata
- this.parseMetadata();
-
- // Store search query for highlighting
- if (searchQuery) {
- this.currentSearchHighlight = searchQuery;
- } else {
- // Clear highlights if no search query
- this.currentSearchHighlight = '';
- }
-
- // Expand folder tree to show the loaded note
- this.expandFolderForNote(notePath);
-
- // Use $nextTick twice to ensure Alpine.js has time to:
- // 1. First tick: expand folders and update DOM
- // 2. Second tick: highlight the note and setup everything else
- this.$nextTick(() => {
- this.$nextTick(() => {
- this.refreshDOMCache();
- this.setupScrollSync();
- this.scrollToTop();
-
- // Apply or clear search highlighting
- if (searchQuery) {
- // Pass true to focus editor when loading from search result
- this.highlightSearchTerm(searchQuery, true);
- } else {
- this.clearSearchHighlights();
- }
-
- // Scroll note into view in sidebar if needed
- this.scrollNoteIntoView(notePath);
- });
- });
-
- } catch (error) {
- ErrorHandler.handle('load note', error);
- }
- },
-
- // Load item (note or media) from URL path
- loadItemFromURL() {
- // Get path from URL (e.g., /folder/note or /folder/image.png)
- let path = window.location.pathname;
-
- // Strip .md extension if present (for MKdocs/Zensical integration)
- if (path.toLowerCase().endsWith('.md')) {
- path = path.slice(0, -3);
- // Update URL bar to show clean path without .md
- window.history.replaceState(null, '', path);
- }
-
- // Skip if root path or static assets
- if (path === '/' || path.startsWith('/static/') || path.startsWith('/api/')) {
- return;
- }
-
- // Remove leading slash and decode URL encoding (e.g., %20 -> space)
- const decodedPath = decodeURIComponent(path.substring(1));
-
- // Check if this is a media file (image, audio, video, PDF)
- const matchedItem = this.notes.find(n => n.path === decodedPath);
-
- if (matchedItem && matchedItem.type !== 'note') {
- // It's a media file, view it
- this.viewMedia(decodedPath, matchedItem.type, false); // false = don't update history
- } else {
- // It's a note, add .md extension and load it
- const notePath = decodedPath + '.md';
-
- // Parse query string for search parameter
- const urlParams = new URLSearchParams(window.location.search);
- const searchParam = urlParams.get('search');
-
- // Try to load the note directly - the backend will handle 404 if it doesn't exist
- // This is more robust than checking the frontend notes list
- this.loadNote(notePath, false, searchParam || '');
-
- // If there's a search parameter, populate the search box and trigger search
- if (searchParam) {
- this.searchQuery = searchParam;
- // Trigger search to populate results list
- this.searchNotes();
- }
- }
- },
-
- // Highlight search term in editor and preview
- highlightSearchTerm(query, focusEditor = false) {
- if (!query || !query.trim()) {
- this.clearSearchHighlights();
- return;
- }
-
- const searchTerm = query.trim();
-
- // Highlight in editor (textarea)
- this.highlightInEditor(searchTerm, focusEditor);
-
- // Highlight in preview (rendered HTML)
- this.highlightInPreview(searchTerm);
- },
-
- // Highlight search term in the editor textarea
- highlightInEditor(searchTerm, shouldFocus = false) {
- const editor = this._domCache.editor || document.getElementById('editor');
- if (!editor) return;
-
- // For textarea, we can't directly highlight text, but we can scroll to first match
- const content = editor.value;
- const lowerContent = content.toLowerCase();
- const lowerTerm = searchTerm.toLowerCase();
- const index = lowerContent.indexOf(lowerTerm);
-
- if (index !== -1) {
- // Calculate line number to scroll to
- const textBefore = content.substring(0, index);
- const lineNumber = textBefore.split('\n').length;
-
- // Scroll to approximate position
- const lineHeight = 20; // Approximate line height in pixels
- editor.scrollTop = (lineNumber - 5) * lineHeight; // Scroll a bit above to show context
-
- // Only focus and select if explicitly requested (e.g., from search result click)
- if (shouldFocus) {
- editor.focus();
- editor.setSelectionRange(index, index + searchTerm.length);
-
- // Blur immediately so the selection stays visible but editor isn't focused
- setTimeout(() => editor.blur(), 100);
- }
- }
- },
-
- // Highlight search term in the preview pane
- highlightInPreview(searchTerm) {
- const preview = document.querySelector('.markdown-preview');
- if (!preview) return;
-
- // Remove existing highlights
- this.clearSearchHighlights();
-
- // Create a tree walker to find all text nodes
- const walker = document.createTreeWalker(
- preview,
- NodeFilter.SHOW_TEXT,
- null,
- false
- );
-
- const textNodes = [];
- let node;
- while (node = walker.nextNode()) {
- // Skip code blocks and pre tags
- if (node.parentElement.tagName === 'CODE' ||
- node.parentElement.tagName === 'PRE') {
- continue;
- }
- textNodes.push(node);
- }
-
- const lowerTerm = searchTerm.toLowerCase();
- let matchIndex = 0;
-
- // Highlight matches in text nodes
- textNodes.forEach(textNode => {
- const text = textNode.textContent;
- const lowerText = text.toLowerCase();
-
- if (lowerText.includes(lowerTerm)) {
- const fragment = document.createDocumentFragment();
- let lastIndex = 0;
- let index;
-
- while ((index = lowerText.indexOf(lowerTerm, lastIndex)) !== -1) {
- // Add text before match
- if (index > lastIndex) {
- fragment.appendChild(
- document.createTextNode(text.substring(lastIndex, index))
- );
- }
-
- // Add highlighted match
- const mark = document.createElement('mark');
- mark.className = 'search-highlight';
- mark.setAttribute('data-match-index', matchIndex);
- mark.textContent = text.substring(index, index + searchTerm.length);
-
- // First match is active (styled via CSS)
- if (matchIndex === 0) {
- mark.classList.add('active-match');
- }
-
- fragment.appendChild(mark);
- matchIndex++;
-
- lastIndex = index + searchTerm.length;
- }
-
- // Add remaining text
- if (lastIndex < text.length) {
- fragment.appendChild(
- document.createTextNode(text.substring(lastIndex))
- );
- }
-
- // Replace text node with highlighted fragment
- textNode.parentNode.replaceChild(fragment, textNode);
- }
- });
-
- // Update total matches and reset current index
- this.totalMatches = matchIndex;
- this.currentMatchIndex = matchIndex > 0 ? 0 : -1;
-
- // Scroll to first match
- if (this.totalMatches > 0) {
- this.scrollToMatch(0);
- }
- },
-
- // Navigate to next search match
- nextMatch() {
- if (this.totalMatches === 0) return;
-
- this.currentMatchIndex = (this.currentMatchIndex + 1) % this.totalMatches;
- this.scrollToMatch(this.currentMatchIndex);
- },
-
- // Navigate to previous search match
- previousMatch() {
- if (this.totalMatches === 0) return;
-
- this.currentMatchIndex = (this.currentMatchIndex - 1 + this.totalMatches) % this.totalMatches;
- this.scrollToMatch(this.currentMatchIndex);
- },
-
- // Scroll to a specific match index
- scrollToMatch(index) {
- const preview = document.querySelector('.markdown-preview');
- if (!preview) return;
-
- const allMatches = preview.querySelectorAll('mark.search-highlight');
- if (index < 0 || index >= allMatches.length) return;
-
- // Update styling - make current match prominent (via CSS class)
- allMatches.forEach((mark, i) => {
- mark.classList.toggle('active-match', i === index);
- });
-
- // Scroll to the match
- const targetMatch = allMatches[index];
- const previewContainer = this._domCache.previewContainer;
- if (previewContainer && targetMatch) {
- const elementTop = targetMatch.offsetTop;
- previewContainer.scrollTop = elementTop - 100; // Scroll with some offset
- }
- },
-
- // Clear search highlights
- clearSearchHighlights() {
- const preview = document.querySelector('.markdown-preview');
- if (!preview) return;
-
- const highlights = preview.querySelectorAll('mark.search-highlight');
- highlights.forEach(mark => {
- const text = document.createTextNode(mark.textContent);
- mark.parentNode.replaceChild(text, mark);
- });
-
- // Normalize text nodes to merge adjacent text nodes
- preview.normalize();
-
- // Reset match counters
- this.totalMatches = 0;
- this.currentMatchIndex = -1;
- },
-
- // =====================================================
- // DROPDOWN MENU SYSTEM
- // =====================================================
-
- toggleNewDropdown(event) {
- this.showNewDropdown = true; // Always open (or keep open)
-
- if (event && event.target) {
- const rect = event.target.getBoundingClientRect();
- // Position dropdown next to the clicked element
- let top = rect.bottom + 4; // 4px spacing
- let left = rect.left;
-
- // Keep dropdown on screen
- const dropdownWidth = 200;
- const dropdownHeight = 150;
- if (left + dropdownWidth > window.innerWidth) {
- left = rect.right - dropdownWidth;
- }
- if (top + dropdownHeight > window.innerHeight) {
- top = rect.top - dropdownHeight - 4;
- }
-
- this.dropdownPosition = { top, left };
- }
- },
-
- closeDropdown() {
- this.showNewDropdown = false;
- this.dropdownTargetFolder = null; // Reset folder context
- },
-
- // =====================================================
- // UNIFIED CREATION FUNCTIONS (reusable from anywhere)
- // =====================================================
-
- // Switch to split view (if in preview-only mode) and focus editor for new notes
- focusEditorForNewNote() {
- // Only switch if in preview-only mode - don't disturb edit or split mode
- if (this.viewMode === 'preview') {
- this.viewMode = 'split';
- this.saveViewMode();
- }
- // Focus the editor after a short delay to ensure DOM is updated
- this.$nextTick(() => {
- const editor = document.getElementById('note-editor');
- if (editor) editor.focus();
- });
- },
-
- async createNote(folderPath = null, directPath = null) {
- let notePath;
-
- if (directPath) {
- // Direct path provided (e.g., from wiki link) - skip prompting
- notePath = directPath.endsWith('.md') ? directPath : `${directPath}.md`;
- } else {
- // Use provided folder path, or dropdown target folder context, or homepage folder
- // Note: Check dropdownTargetFolder !== null to distinguish between '' (root) and not set
- let targetFolder;
- if (folderPath !== null) {
- targetFolder = folderPath;
- } else if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) {
- targetFolder = this.dropdownTargetFolder; // Can be '' for root or a folder path
- } else {
- targetFolder = this.selectedHomepageFolder || '';
- }
- this.closeDropdown();
-
- const promptText = targetFolder
- ? this.t('notes.prompt_name_in_folder', { folder: targetFolder })
- : this.t('notes.prompt_name_with_path');
-
- const noteName = prompt(promptText);
- if (!noteName) return;
-
- // Validate the name/path (may contain / for paths when no target folder)
- const validation = targetFolder
- ? FilenameValidator.validateFilename(noteName)
- : FilenameValidator.validatePath(noteName);
-
- if (!validation.valid) {
- alert(this.getValidationErrorMessage(validation, 'note'));
- return;
- }
-
- const validatedName = validation.sanitized;
-
- if (targetFolder) {
- notePath = `${targetFolder}/${validatedName}.md`;
- } else {
- notePath = validatedName.endsWith('.md') ? validatedName : `${validatedName}.md`;
- }
- }
-
- // CRITICAL: Check if note already exists (applies to both prompt and direct path)
- const existingNote = this.notes.find(note => note.path === notePath);
- if (existingNote) {
- alert(this.t('notes.already_exists', { name: notePath }));
- return;
- }
-
- try {
- const response = await fetch(`/api/notes/${notePath}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ content: '' })
- });
-
- if (response.ok) {
- // Expand parent folder if note is in a subfolder
- const folderPart = notePath.includes('/') ? notePath.substring(0, notePath.lastIndexOf('/')) : '';
- if (folderPart) this.expandedFolders.add(folderPart);
- await this.loadNotes();
- await this.loadNote(notePath);
- this.focusEditorForNewNote();
- } else {
- ErrorHandler.handle('create note', new Error('Server returned error'));
- }
- } catch (error) {
- ErrorHandler.handle('create note', error);
- }
- },
-
- async createFolder(parentPath = null) {
- // Use provided parent path, or dropdown target folder context, or homepage folder
- // Note: Check dropdownTargetFolder !== null to distinguish between '' (root) and not set
- let targetFolder;
- if (parentPath !== null) {
- targetFolder = parentPath;
- } else if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) {
- targetFolder = this.dropdownTargetFolder; // Can be '' for root or a folder path
- } else {
- targetFolder = this.selectedHomepageFolder || '';
- }
- this.closeDropdown();
-
- const promptText = targetFolder
- ? this.t('folders.prompt_name_in_folder', { folder: targetFolder })
- : this.t('folders.prompt_name_with_path');
-
- const folderName = prompt(promptText);
- if (!folderName) return;
-
- // Validate the name/path (may contain / for paths when no target folder)
- const validation = targetFolder
- ? FilenameValidator.validateFilename(folderName)
- : FilenameValidator.validatePath(folderName);
-
- if (!validation.valid) {
- alert(this.getValidationErrorMessage(validation, 'folder'));
- return;
- }
-
- const validatedName = validation.sanitized;
- const folderPath = targetFolder ? `${targetFolder}/${validatedName}` : validatedName;
-
- // Check if folder already exists
- const existingFolder = this.allFolders.find(folder => folder === folderPath);
- if (existingFolder) {
- alert(this.t('folders.already_exists', { name: validatedName }));
- return;
- }
-
- try {
- const response = await fetch('/api/folders', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ path: folderPath })
- });
-
- if (response.ok) {
- if (targetFolder) {
- this.expandedFolders.add(targetFolder);
- }
- this.expandedFolders.add(folderPath);
- await this.loadNotes();
-
- // Navigate to the newly created folder on the homepage
- this.goToHomepageFolder(folderPath);
- } else {
- ErrorHandler.handle('create folder', new Error('Server returned error'));
- }
- } catch (error) {
- ErrorHandler.handle('create folder', error);
- }
- },
-
- // Rename a folder
- async renameFolder(folderPath, currentName) {
- const newName = prompt(this.t('folders.prompt_rename', { name: currentName }), currentName);
- if (!newName || newName === currentName) return;
-
- // Validate the new name (single segment, no path separators)
- const validation = FilenameValidator.validateFilename(newName);
- if (!validation.valid) {
- alert(this.getValidationErrorMessage(validation, 'folder'));
- return;
- }
-
- const validatedName = validation.sanitized;
-
- // Calculate new path
- const pathParts = folderPath.split('/');
- pathParts[pathParts.length - 1] = validatedName;
- const newPath = pathParts.join('/');
-
- 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) {
- // Update expanded folders state
- if (this.expandedFolders.has(folderPath)) {
- this.expandedFolders.delete(folderPath);
- this.expandedFolders.add(newPath);
- }
-
- // Update favorites that were in the renamed folder
- const folderPrefix = folderPath + '/';
- const newFolderPrefix = newPath + '/';
- const newFavorites = this.favorites.map(f => {
- if (f.startsWith(folderPrefix)) {
- return f.replace(folderPrefix, newFolderPrefix);
- }
- return f;
- });
- // Check if anything changed
- if (JSON.stringify(newFavorites) !== JSON.stringify(this.favorites)) {
- this.favorites = newFavorites;
- this.favoritesSet = new Set(newFavorites);
- this.saveFavorites();
- }
-
- // Update current note path if it's in the renamed folder
- if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
- this.currentNote = this.currentNote.replace(folderPrefix, newFolderPrefix);
- }
-
- await this.loadNotes();
- } else {
- ErrorHandler.handle('rename folder', new Error('Server returned error'));
- }
- } catch (error) {
- ErrorHandler.handle('rename folder', error);
- }
- },
-
- // Delete folder
- async deleteFolder(folderPath, folderName) {
- const confirmation = confirm(this.t('folders.confirm_delete', { name: folderName }));
-
- if (!confirmation) 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 newFavorites = this.favorites.filter(f => !f.startsWith(folderPrefix));
- if (newFavorites.length !== this.favorites.length) {
- this.favorites = newFavorites;
- this.favoritesSet = new Set(newFavorites);
- this.saveFavorites();
- }
-
- // Clear current note if it was in the deleted folder
- if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
- this.currentNote = '';
- this.noteContent = '';
- document.title = this.appName;
- }
-
- await this.loadNotes();
- } else {
- ErrorHandler.handle('delete folder', new Error('Server returned error'));
- }
- } catch (error) {
- ErrorHandler.handle('delete folder', error);
- }
- },
-
- // Auto-save with debounce
- autoSave() {
- if (this.saveTimeout) {
- clearTimeout(this.saveTimeout);
- }
-
- this.lastSaved = false;
-
- // Push to undo history (but not during undo/redo operations)
- if (!this.isUndoRedo) {
- this.pushToHistory();
- }
-
- // Calculate stats in real-time if plugin enabled
- if (this.statsPluginEnabled) {
- this.calculateStats();
- }
-
- // Parse metadata in real-time
- this.parseMetadata();
-
- // Update outline (TOC) in real-time
- this.extractOutline(this.noteContent);
-
- this.saveTimeout = setTimeout(() => {
- // Commit to undo history when autosave triggers (same debounce timing)
- if (this.hasPendingHistoryChanges) {
- this.commitToHistory();
- }
- this.saveNote();
- }, CONFIG.AUTOSAVE_DELAY);
- },
-
- // Mark that we have pending changes (called on each keystroke)
- pushToHistory() {
- this.hasPendingHistoryChanges = true;
- },
-
- // Immediately commit pending changes to history (call before undo/redo)
- flushHistory() {
- if (this.hasPendingHistoryChanges) {
- this.commitToHistory();
- }
- },
-
- // Actually commit to undo history (internal)
- commitToHistory() {
- const editor = document.getElementById('note-editor');
- const cursorPos = editor ? editor.selectionStart : 0;
-
- // Only push if content actually changed from last history entry
- if (this.undoHistory.length > 0 &&
- this.undoHistory[this.undoHistory.length - 1].content === this.noteContent) {
- this.hasPendingHistoryChanges = false;
- return;
- }
-
- this.undoHistory.push({ content: this.noteContent, cursorPos });
-
- // Limit history size
- if (this.undoHistory.length > this.maxHistorySize) {
- this.undoHistory.shift();
- }
-
- // Clear redo history when new change is made
- this.redoHistory = [];
- this.hasPendingHistoryChanges = false;
- },
-
- // Undo last change
- undo() {
- if (!this.currentNote) return;
-
- // Flush any pending history changes first (so we don't lose unsaved edits)
- this.flushHistory();
-
- if (this.undoHistory.length <= 1) return;
-
- const editor = document.getElementById('note-editor');
-
- // Pop current state to redo history
- const currentState = this.undoHistory.pop();
- this.redoHistory.push(currentState);
-
- // Get previous state
- const previousState = this.undoHistory[this.undoHistory.length - 1];
-
- // Apply previous state
- this.isUndoRedo = true;
- this.noteContent = previousState.content;
-
- // Recalculate stats with new content
- if (this.statsPluginEnabled) {
- this.calculateStats();
- }
-
- // Restore cursor position from the state we're going back to
- this.$nextTick(() => {
- this.saveNote();
- this.isUndoRedo = false;
- if (editor) {
- setTimeout(() => {
- const newPos = Math.min(previousState.cursorPos, this.noteContent.length);
- editor.setSelectionRange(newPos, newPos);
- editor.focus();
- }, 0);
- }
- });
- },
-
- // Redo last undone change
- redo() {
- if (!this.currentNote) return;
-
- // Flush any pending history changes first
- this.flushHistory();
-
- if (this.redoHistory.length === 0) return;
-
- const editor = document.getElementById('note-editor');
-
- // Pop from redo history
- const nextState = this.redoHistory.pop();
-
- // Push to undo history
- this.undoHistory.push(nextState);
-
- // Apply next state
- this.isUndoRedo = true;
- this.noteContent = nextState.content;
-
- // Recalculate stats with new content
- if (this.statsPluginEnabled) {
- this.calculateStats();
- }
-
- // Restore cursor position from the state we're going forward to
- this.$nextTick(() => {
- this.saveNote();
- this.isUndoRedo = false;
- if (editor) {
- setTimeout(() => {
- const newPos = Math.min(nextState.cursorPos, this.noteContent.length);
- editor.setSelectionRange(newPos, newPos);
- editor.focus();
- }, 0);
- }
- });
- },
-
- // Markdown formatting helpers
- wrapSelection(before, after, placeholder) {
- const editor = document.getElementById('note-editor');
- if (!editor) return;
-
- const start = editor.selectionStart;
- const end = editor.selectionEnd;
- const selectedText = this.noteContent.substring(start, end);
- const textToWrap = selectedText || placeholder;
-
- // Build the new text
- const newText = before + textToWrap + after;
-
- // Update content
- this.noteContent = this.noteContent.substring(0, start) + newText + this.noteContent.substring(end);
-
- // Set cursor position (select the wrapped text or placeholder)
- this.$nextTick(() => {
- if (selectedText) {
- // If text was selected, keep it selected (inside the wrapper)
- editor.setSelectionRange(start + before.length, start + before.length + selectedText.length);
- } else {
- // If no text selected, select the placeholder
- editor.setSelectionRange(start + before.length, start + before.length + placeholder.length);
- }
- editor.focus();
- });
-
- // Trigger autosave
- this.autoSave();
- },
-
- insertLink() {
- const editor = document.getElementById('note-editor');
- if (!editor) return;
-
- const start = editor.selectionStart;
- const end = editor.selectionEnd;
- const selectedText = this.noteContent.substring(start, end);
-
- // If text is selected, use it as link text; otherwise use placeholder
- const linkText = selectedText || 'link text';
- const linkUrl = 'url';
-
- // Build the markdown link
- const newText = `[${linkText}](${linkUrl})`;
-
- // Update content
- this.noteContent = this.noteContent.substring(0, start) + newText + this.noteContent.substring(end);
-
- // Set cursor position to select the URL part for easy editing
- this.$nextTick(() => {
- const urlStart = start + linkText.length + 3; // After "[linkText]("
- const urlEnd = urlStart + linkUrl.length;
- editor.setSelectionRange(urlStart, urlEnd);
- editor.focus();
- });
-
- // Trigger autosave
- this.autoSave();
- },
-
- // Insert a markdown table placeholder
- insertTable() {
- const editor = document.getElementById('note-editor');
- if (!editor) return;
-
- const cursorPos = editor.selectionStart;
-
- // Basic 3x3 table placeholder
- const table = `| Header 1 | Header 2 | Header 3 |
-|----------|----------|----------|
-| Cell 1 | Cell 2 | Cell 3 |
-| Cell 4 | Cell 5 | Cell 6 |
-`;
-
- // Add newline before if not at start of line
- const textBefore = this.noteContent.substring(0, cursorPos);
- const needsNewlineBefore = textBefore.length > 0 && !textBefore.endsWith('\n');
- const prefix = needsNewlineBefore ? '\n\n' : '';
-
- // Insert the table
- this.noteContent = textBefore + prefix + table + this.noteContent.substring(cursorPos);
-
- // Position cursor at first header for easy editing
- this.$nextTick(() => {
- const newPos = cursorPos + prefix.length + 2; // After "| "
- editor.setSelectionRange(newPos, newPos + 8); // Select "Header 1"
- editor.focus();
- });
-
- // Trigger autosave
- this.autoSave();
- },
-
- // Format selected text or insert formatting at cursor
- formatText(type) {
- // Simple wrap cases - reuse wrapSelection()
- const wrapFormats = {
- 'bold': ['**', '**', 'bold'],
- 'italic': ['*', '*', 'italic'],
- 'strikethrough': ['~~', '~~', 'strikethrough'],
- 'code': ['`', '`', 'code']
- };
-
- if (wrapFormats[type]) {
- const [before, after, placeholder] = wrapFormats[type];
- this.wrapSelection(before, after, placeholder);
- return;
- }
-
- // Special cases that need custom handling
- switch (type) {
- case 'heading':
- this.insertLinePrefix('## ', 'Heading');
- break;
- case 'quote':
- this.insertLinePrefix('> ', 'quote');
- break;
- case 'bullet':
- this.insertLinePrefix('- ', 'item');
- break;
- case 'numbered':
- this.insertLinePrefix('1. ', 'item');
- break;
- case 'checkbox':
- this.insertLinePrefix('- [ ] ', 'task');
- break;
- case 'link':
- this.insertLink();
- break;
- case 'image':
- this.wrapSelection('', 'alt text');
- break;
- case 'codeblock':
- this.wrapSelection('```\n', '\n```', 'code');
- break;
- case 'table':
- this.insertTable();
- break;
- }
- },
-
- // Insert a line prefix (for headings, lists, quotes)
- insertLinePrefix(prefix, placeholder) {
- const editor = document.getElementById('note-editor');
- if (!editor) return;
-
- const start = editor.selectionStart;
- const end = editor.selectionEnd;
- const selectedText = this.noteContent.substring(start, end);
- const beforeText = this.noteContent.substring(0, start);
- const afterText = this.noteContent.substring(end);
-
- // Check if at start of line
- const atLineStart = beforeText.endsWith('\n') || beforeText === '';
- const newline = atLineStart ? '' : '\n';
-
- let replacement;
- if (selectedText) {
- // Prefix each line of selection
- replacement = newline + selectedText.split('\n').map((line, i) => {
- // For numbered lists, increment the number
- if (prefix === '1. ') return `${i + 1}. ${line}`;
- return prefix + line;
- }).join('\n');
- } else {
- replacement = newline + prefix + placeholder;
- }
-
- this.noteContent = beforeText + replacement + afterText;
-
- this.$nextTick(() => {
- if (selectedText) {
- editor.setSelectionRange(start + newline.length, start + replacement.length);
- } else {
- const placeholderStart = start + newline.length + prefix.length;
- editor.setSelectionRange(placeholderStart, placeholderStart + placeholder.length);
- }
- editor.focus();
- });
-
- this.autoSave();
- },
-
- // Save current note
- async saveNote() {
- if (!this.currentNote) return;
-
- this.isSaving = true;
-
- try {
- const response = await fetch(`/api/notes/${this.currentNote}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ content: this.noteContent })
- });
-
- if (response.ok) {
- this.lastSaved = true;
-
- // Update only the modified timestamp for the current note (no full reload needed)
- const note = this.notes.find(n => n.path === this.currentNote);
- if (note) {
- note.modified = new Date().toISOString();
- note.size = new Blob([this.noteContent]).size;
-
- // Parse tags from content
- note.tags = this.parseTagsFromContent(this.noteContent);
- }
-
- // Reload tags to update sidebar counts (debounced to prevent spam)
- this.loadTagsDebounced();
-
- // Rebuild folder tree if tag filters are active
- if (this.selectedTags.length > 0) {
- this.buildFolderTree();
- }
-
- // Hide "saved" indicator
- setTimeout(() => {
- this.lastSaved = false;
- }, CONFIG.SAVE_INDICATOR_DURATION);
- } else {
- ErrorHandler.handle('save note', new Error('Server returned error'));
- }
- } catch (error) {
- ErrorHandler.handle('save note', error);
- } finally {
- this.isSaving = false;
- }
- },
-
- // Rename current note
- async renameNote() {
- if (!this.currentNote) return;
-
- const oldPath = this.currentNote;
- const newName = this.currentNoteName.trim();
-
- if (!newName) {
- alert(this.t('notes.empty_name'));
- return;
- }
-
- // Validate the new name (single segment, no path separators)
- const validation = FilenameValidator.validateFilename(newName);
- if (!validation.valid) {
- alert(this.getValidationErrorMessage(validation, 'note'));
- // Reset the name in the UI
- this.currentNoteName = oldPath.split('/').pop().replace('.md', '');
- return;
- }
-
- const validatedName = validation.sanitized;
- const folder = oldPath.split('/').slice(0, -1).join('/');
- const newPath = folder ? `${folder}/${validatedName}.md` : `${validatedName}.md`;
-
- if (oldPath === newPath) return;
-
- // Check if a note with the new name already exists
- const existingNote = this.notes.find(n => n.path.toLowerCase() === newPath.toLowerCase());
- if (existingNote) {
- alert(this.t('notes.already_exists', { name: validatedName }));
- // Reset the name in the UI
- this.currentNoteName = oldPath.split('/').pop().replace('.md', '');
- return;
- }
-
- // Create new note with same content
- try {
- const response = await fetch(`/api/notes/${newPath}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ content: this.noteContent })
- });
-
- if (response.ok) {
- // Delete old note
- await fetch(`/api/notes/${oldPath}`, { method: 'DELETE' });
-
- // Update favorites if the renamed note was favorited
- if (this.favoritesSet.has(oldPath)) {
- const newFavorites = this.favorites.map(f => f === oldPath ? newPath : f);
- this.favorites = newFavorites;
- this.favoritesSet = new Set(newFavorites);
- this.saveFavorites();
- }
-
- this.currentNote = newPath;
- await this.loadNotes();
- } else {
- ErrorHandler.handle('rename note', new Error('Server returned error'));
- }
- } catch (error) {
- ErrorHandler.handle('rename note', error);
- }
- },
-
- // Delete current note
- async deleteCurrentNote() {
- if (!this.currentNote) return;
-
- // Just call deleteNote with current note details
- await this.deleteNote(this.currentNote, this.currentNoteName);
- },
-
- // Delete any note from sidebar
- async deleteNote(notePath, noteName) {
- if (!confirm(this.t('notes.confirm_delete', { name: noteName }))) return;
-
- try {
- const response = await fetch(`/api/notes/${notePath}`, {
- method: 'DELETE'
- });
-
- if (response.ok) {
- // Remove from favorites if it was favorited
- if (this.favoritesSet.has(notePath)) {
- const newFavorites = this.favorites.filter(f => f !== notePath);
- this.favorites = newFavorites;
- this.favoritesSet = new Set(newFavorites);
- this.saveFavorites();
- }
-
- // If the deleted note is currently open, clear it
- if (this.currentNote === notePath) {
- this.currentNote = '';
- this.noteContent = '';
- this.currentNoteName = '';
- this._lastRenderedContent = ''; // Clear render cache
- this._cachedRenderedHTML = '';
- document.title = this.appName;
- // Redirect to root
- window.history.replaceState({}, '', '/');
- }
-
- await this.loadNotes();
- } else {
- ErrorHandler.handle('delete note', new Error('Server returned error'));
- }
- } catch (error) {
- ErrorHandler.handle('delete note', error);
- }
- },
-
- // Search notes
- debouncedSearchNotes() {
- if (this.searchDebounceTimeout) {
- clearTimeout(this.searchDebounceTimeout);
- }
-
- const hasTextSearch = this.searchQuery.trim().length > 0;
- if (!hasTextSearch) {
- this.isSearching = false;
- this.searchNotes();
- return;
- }
-
- this.isSearching = true;
- this.searchResults = [];
-
- this.searchDebounceTimeout = setTimeout(() => {
- this.searchNotes();
- }, CONFIG.SEARCH_DEBOUNCE_DELAY);
- },
-
- // Search notes by text (calls unified filter logic)
- async searchNotes() {
- await this.applyFilters();
- },
-
- // Trigger MathJax typesetting after DOM update
- typesetMath() {
- if (typeof MathJax !== 'undefined' && MathJax.typesetPromise) {
- // Use a small delay to ensure DOM is updated
- setTimeout(() => {
- const previewContent = document.querySelector('.markdown-preview');
- if (previewContent) {
- MathJax.typesetPromise([previewContent]).catch((err) => {
- console.error('MathJax typesetting failed:', err);
- });
- }
- }, 10);
- }
- },
-
- // Render Mermaid diagrams
- async renderMermaid() {
- if (typeof window.mermaid === 'undefined') {
- console.warn('Mermaid not loaded yet');
- return;
- }
-
- // Use requestAnimationFrame for better performance than setTimeout
- requestAnimationFrame(async () => {
- const previewContent = document.querySelector('.markdown-preview');
- if (!previewContent) return;
-
- // Get the appropriate theme based on current app theme
- const themeType = this.getThemeType();
- const mermaidTheme = themeType === 'light' ? 'default' : 'dark';
-
- // Only reinitialize if theme changed (performance optimization)
- if (this.lastMermaidTheme !== mermaidTheme) {
- window.mermaid.initialize({
- startOnLoad: false,
- theme: mermaidTheme,
- securityLevel: 'strict', // Use strict for better security
- fontFamily: 'inherit',
- // v11 changed useMaxWidth defaults - restore responsive behavior
- flowchart: { useMaxWidth: true },
- sequence: { useMaxWidth: true },
- gantt: { useMaxWidth: true },
- journey: { useMaxWidth: true },
- timeline: { useMaxWidth: true },
- class: { useMaxWidth: true },
- state: { useMaxWidth: true },
- er: { useMaxWidth: true },
- pie: { useMaxWidth: true },
- quadrantChart: { useMaxWidth: true },
- requirement: { useMaxWidth: true },
- mindmap: { useMaxWidth: true },
- gitGraph: { useMaxWidth: true }
- });
- this.lastMermaidTheme = mermaidTheme;
- }
-
- // Find all code blocks with language 'mermaid'
- const mermaidBlocks = previewContent.querySelectorAll('pre code.language-mermaid');
-
- // Early return if no diagrams to render
- if (mermaidBlocks.length === 0) return;
-
- for (let i = 0; i < mermaidBlocks.length; i++) {
- const block = mermaidBlocks[i];
- const pre = block.parentElement;
-
- // Skip if already rendered (performance optimization)
- if (pre.querySelector('.mermaid-rendered')) continue;
-
- try {
- const code = block.textContent;
- const id = `mermaid-diagram-${Date.now()}-${i}`;
-
- // Render the diagram
- const { svg } = await window.mermaid.render(id, code);
-
- // Create a container for the rendered diagram
- const container = document.createElement('div');
- container.className = 'mermaid-rendered';
- container.style.cssText = 'background-color: transparent; padding: 20px; text-align: center; overflow-x: auto;';
- container.innerHTML = svg;
- // Store original code for theme re-rendering
- container.dataset.originalCode = code;
-
- // Replace the code block with the rendered diagram
- pre.parentElement.replaceChild(container, pre);
- } catch (error) {
- console.error('Mermaid rendering error:', error);
- // Add error indicator to the code block
- const errorMsg = document.createElement('div');
- errorMsg.style.cssText = 'color: var(--error); padding: 10px; border-left: 3px solid var(--error); margin-top: 10px;';
- errorMsg.textContent = `โ ๏ธ Mermaid diagram error: ${error.message}`;
- pre.parentElement.insertBefore(errorMsg, pre.nextSibling);
- }
- }
- });
- },
-
- // Get current theme type (light or dark)
- // Returns: 'light' or 'dark'
- // Used by features that need to adapt to theme brightness (e.g., Mermaid diagrams, Chart.js)
- getThemeType() {
- // Handle system theme
- if (this.currentTheme === 'system') {
- const isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
- return isDark ? 'dark' : 'light';
- }
-
- // Try to get theme type from loaded theme metadata
- const currentThemeData = this.availableThemes.find(t => t.id === this.currentTheme);
- if (currentThemeData && currentThemeData.type) {
- // Use metadata from theme file (light or dark)
- return currentThemeData.type; // Already 'light' or 'dark'
- }
-
- // Backward compatibility: fallback to hardcoded map if metadata not available
- const fallbackMap = {
- 'light': 'light',
- 'vs-blue': 'light'
- };
-
- return fallbackMap[this.currentTheme] || 'dark';
- },
-
-
- // Computed property for rendered markdown
- get renderedMarkdown() {
- if (!this.noteContent) return 'Nothing to preview yet...
';
-
- // Performance: Return cached HTML if content hasn't changed
- if (this.noteContent === this._lastRenderedContent && this._cachedRenderedHTML) {
- return this._cachedRenderedHTML;
- }
-
- // Strip YAML frontmatter from content before rendering
- let contentToRender = this.noteContent;
- if (contentToRender.trim().startsWith('---')) {
- const lines = contentToRender.split('\n');
- if (lines[0].trim() === '---') {
- // Find closing ---
- let endIdx = -1;
- for (let i = 1; i < lines.length; i++) {
- if (lines[i].trim() === '---') {
- endIdx = i;
- break;
- }
- }
- if (endIdx !== -1) {
- // Remove frontmatter (including the closing ---) and any empty lines after it
- contentToRender = lines.slice(endIdx + 1).join('\n').trim();
- }
- }
- }
-
- // Convert Obsidian-style wikilinks: [[note]] or [[note|display text]]
- // Must be done before marked.parse() to avoid conflicts with markdown syntax
- // BUT we need to protect code blocks first to avoid converting [[text]] inside code
- const self = this; // Reference for closure
-
- // Step 1: Temporarily replace code blocks and inline code with placeholders
- const codeBlocks = [];
- // Protect fenced code blocks (```...```)
- contentToRender = contentToRender.replace(/```[\s\S]*?```/g, (match) => {
- codeBlocks.push(match);
- return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`;
- });
- // Protect inline code (`...`)
- contentToRender = contentToRender.replace(/`[^`]+`/g, (match) => {
- codeBlocks.push(match);
- return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`;
- });
-
- // Step 2: Convert media wikilinks FIRST: ![[file.png]] or ![[file.png|alt text]]
- // Must be before note wikilinks to prevent [[file.png]] from being matched first
- contentToRender = contentToRender.replace(
- /!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
- (match, mediaName, altText) => {
- const filename = mediaName.trim();
- const alt = altText ? altText.trim() : filename.replace(/\.[^/.]+$/, '');
-
- // Resolve media path using O(1) lookup
- const mediaPath = self.resolveMediaWikilink(filename);
-
- if (mediaPath) {
- // URL-encode path segments for the API
- const encodedPath = mediaPath.split('/').map(segment => {
- try {
- return encodeURIComponent(decodeURIComponent(segment));
- } catch (e) {
- return encodeURIComponent(segment);
- }
- }).join('/');
-
- const safeAlt = alt.replace(/"/g, '"');
- const mediaSrc = `/api/media/${encodedPath}`;
- const mediaType = self.getMediaType(filename);
-
- // Return appropriate HTML based on media type
- switch (mediaType) {
- case 'audio':
- return ``;
- case 'video':
- return ``;
- case 'document':
- // Local PDFs: show iframe preview
- return ``;
- default: // image
- return `
`;
- }
- }
-
- // Media not found - return broken indicator
- const safeFilename = filename.replace(/&/g, '&').replace(//g, '>');
- const mediaType = self.getMediaType(filename);
- const icon = mediaType === 'audio' ? '๐ต' : mediaType === 'video' ? '๐ฌ' : mediaType === 'document' ? '๐' : '๐ผ๏ธ';
- return `${icon} ${safeFilename}`;
- }
- );
-
- // Step 2b: Convert note wikilinks: [[note]] or [[note|display text]]
- contentToRender = contentToRender.replace(
- /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
- (match, target, displayText) => {
- const linkTarget = target.trim();
- const linkText = displayText ? displayText.trim() : linkTarget;
-
- // Fast O(1) check using pre-built lookup maps
- // Handle section anchors: extract base note path
- const hashIndex = linkTarget.indexOf('#');
- const basePath = hashIndex !== -1 ? linkTarget.substring(0, hashIndex) : linkTarget;
- const noteExists = basePath === '' || self.wikiLinkExists(basePath);
-
- // Escape special chars: href needs quote escaping, text needs HTML escaping
- const safeHref = linkTarget.replace(/"/g, '%22');
- const safeText = linkText.replace(/&/g, '&').replace(//g, '>');
-
- // Return link with data attribute for styling broken links
- const brokenClass = noteExists ? '' : ' class="wikilink-broken"';
- return `${safeText}`;
- }
- );
-
- // Step 3: Restore code blocks
- contentToRender = contentToRender.replace(/\x00CODEBLOCK(\d+)\x00/g, (match, index) => {
- return codeBlocks[parseInt(index)];
- });
-
- // Protect LaTeX \(...\) and \[...\] delimiters from marked.js escaping
- marked.use({
- extensions: [{
- name: 'protectLatexMath',
- level: 'inline',
- start(src) { return src.match(/\\[\(\[]/)?.index; },
- tokenizer(src) {
- // Match \(...\) or \[...\]
- const match = src.match(/^(\\[\(\[])([\s\S]*?)(\\[\)\]])/);
- if (match) {
- return {
- type: 'html',
- raw: match[0],
- text: match[0]
- };
- }
- }
- }]
- });
-
- // Configure marked with syntax highlighting
- marked.setOptions({
- breaks: true,
- gfm: true,
- highlight: function(code, lang) {
- if (lang && hljs.getLanguage(lang)) {
- try {
- return hljs.highlight(code, { language: lang }).value;
- } catch (err) {
- console.error('Highlight error:', err);
- }
- }
- return hljs.highlightAuto(code).value;
- }
- });
-
- // Parse markdown
- let html = marked.parse(contentToRender);
-
- // Sanitize HTML to prevent XSS attacks
- // DOMPurify defaults allow most HTML/SVG tags but strip scripts, iframes, and event handlers
- // MathJax and Mermaid run AFTER this, so their elements don't need whitelisting
- html = DOMPurify.sanitize(html);
-
- // Post-process: Add target="_blank" to external links and title attributes to images
- // Parse as DOM to safely manipulate
- const tempDiv = document.createElement('div');
- tempDiv.innerHTML = html;
-
- // Find all links
- const links = tempDiv.querySelectorAll('a');
- links.forEach(link => {
- const href = link.getAttribute('href');
- if (href && typeof href === 'string') {
- // Check if it's an external link
- const isExternal = href.indexOf('http://') === 0 ||
- href.indexOf('https://') === 0 ||
- href.indexOf('//') === 0;
-
- if (isExternal) {
- link.setAttribute('target', '_blank');
- link.setAttribute('rel', 'noopener noreferrer');
- }
- }
- });
-
- // Find all images and transform paths for display
- // Also convert non-image media (audio, video, PDF) to appropriate elements
- const images = tempDiv.querySelectorAll('img');
- images.forEach(img => {
- let src = img.getAttribute('src');
- if (src) {
- const isExternal = src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//');
- const isLocal = !isExternal && !src.startsWith('data:');
-
- // Transform relative paths to /api/media/ for serving
- if (isLocal && !src.startsWith('/api/media/')) {
- // URL-encode path segments to handle spaces and special characters
- const encodedPath = src.split('/').map(segment => {
- try {
- return encodeURIComponent(decodeURIComponent(segment));
- } catch (e) {
- return encodeURIComponent(segment);
- }
- }).join('/');
- src = `/api/media/${encodedPath}`;
- img.setAttribute('src', src);
- }
-
- // Check if this is non-image media and convert to appropriate element
- const mediaType = self.getMediaType(src);
- const altText = img.getAttribute('alt') || src.split('/').pop().replace(/\.[^/.]+$/, '');
- const safeAlt = altText.replace(/"/g, '"');
-
- // Only convert LOCAL media to embedded elements (security)
- // External non-image media gets styled links instead
- if (isLocal || src.startsWith('/api/media/')) {
- if (mediaType === 'audio') {
- const wrapper = document.createElement('div');
- wrapper.className = 'media-embed media-audio';
- wrapper.innerHTML = `${safeAlt}`;
- img.replaceWith(wrapper);
- return;
- } else if (mediaType === 'video') {
- const wrapper = document.createElement('div');
- wrapper.className = 'media-embed media-video';
- wrapper.innerHTML = ``;
- img.replaceWith(wrapper);
- return;
- } else if (mediaType === 'document') {
- // Local PDFs: show iframe preview
- const wrapper = document.createElement('div');
- wrapper.className = 'media-embed media-pdf';
- wrapper.innerHTML = ``;
- img.replaceWith(wrapper);
- return;
- }
- } else if (isExternal && mediaType === 'document') {
- // External PDFs: styled link (opens in new tab)
- const link = document.createElement('a');
- link.href = src;
- link.target = '_blank';
- link.rel = 'noopener noreferrer';
- link.className = 'pdf-link';
- link.title = `Open ${safeAlt}`;
- link.innerHTML = `๐ ${safeAlt}Opens in new tab`;
- img.replaceWith(link);
- return;
- }
- // External audio/video: leave as broken image for security
- }
-
- // For regular images, set title attribute
- const altText = img.getAttribute('alt');
- if (altText) {
- img.setAttribute('title', altText);
- }
- });
-
- html = tempDiv.innerHTML;
-
- // Debounced MathJax rendering (avoid re-running on every keystroke)
- if (this._mathDebounceTimeout) clearTimeout(this._mathDebounceTimeout);
- this._mathDebounceTimeout = setTimeout(() => this.typesetMath(), 300);
-
- // Debounced Mermaid rendering
- if (this._mermaidDebounceTimeout) clearTimeout(this._mermaidDebounceTimeout);
- this._mermaidDebounceTimeout = setTimeout(() => this.renderMermaid(), 300);
-
- // Apply syntax highlighting and add copy buttons to code blocks
- setTimeout(() => {
- // Use cached reference if available, otherwise query
- const previewEl = this._domCache.previewContent || document.querySelector('.markdown-preview');
- if (previewEl) {
- // Exclude code blocks that are rendered by other tools (e.g., Mermaid diagrams)
- // Note: MathJax uses $$...$$ delimiters (not code blocks) so no exclusion needed
- previewEl.querySelectorAll('pre code:not(.language-mermaid)').forEach((block) => {
- // Apply syntax highlighting
- if (!block.classList.contains('hljs')) {
- hljs.highlightElement(block);
- }
-
- // Add copy button if not already present
- const pre = block.parentElement;
- if (pre && !pre.querySelector('.copy-code-button')) {
- this.addCopyButtonToCodeBlock(pre);
- }
- });
-
- // Enable video metadata loading (for first frame preview)
- // Track by source URL to prevent duplicate requests on re-renders
- if (!this._initializedVideoSources) this._initializedVideoSources = new Set();
- previewEl.querySelectorAll('video[preload="none"]').forEach((video) => {
- const src = video.getAttribute('src');
- if (src && !this._initializedVideoSources.has(src)) {
- this._initializedVideoSources.add(src);
- video.preload = 'metadata';
- }
- });
- }
- }, 0);
-
- // Cache the result for performance
- this._lastRenderedContent = this.noteContent;
- this._cachedRenderedHTML = html;
-
- return html;
- },
-
- // Refresh DOM element cache
- refreshDOMCache() {
- this._domCache.editor = document.querySelector('.editor-textarea');
- this._domCache.previewContent = document.querySelector('.markdown-preview');
- this._domCache.previewContainer = this._domCache.previewContent ? this._domCache.previewContent.parentElement : null;
- },
-
- // Add copy button to code block
- addCopyButtonToCodeBlock(preElement) {
- // Extract language from code element class (e.g., "language-toml" -> "TOML")
- const codeElement = preElement.querySelector('code');
- let language = '';
- if (codeElement && codeElement.className) {
- const match = codeElement.className.match(/language-(\w+)/);
- if (match) {
- const langMap = {
- 'js': 'JavaScript', 'ts': 'TypeScript', 'py': 'Python',
- 'rb': 'Ruby', 'cs': 'C#', 'cpp': 'C++', 'sh': 'Shell',
- 'bash': 'Bash', 'zsh': 'Zsh', 'yml': 'YAML', 'md': 'Markdown'
- };
- const rawLang = match[1].toLowerCase();
- language = langMap[rawLang] || match[1].toUpperCase();
- }
- }
-
- // Create copy button with language label
- const button = document.createElement('button');
- button.className = 'copy-code-button';
- const displayText = language || this.t('common.copy_to_clipboard').split(' ')[0]; // Use first word as fallback
- button.innerHTML = `${displayText}`;
- button.dataset.originalText = displayText; // Store for restore after copy
- button.title = this.t('common.copy_to_clipboard');
-
- // Style the button
- button.style.position = 'absolute';
- button.style.top = '8px';
- button.style.right = '8px';
- button.style.padding = '4px 10px';
- button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
- button.style.border = 'none';
- button.style.borderRadius = '4px';
- button.style.cursor = 'pointer';
- button.style.opacity = '0';
- button.style.transition = 'opacity 0.2s, background-color 0.2s';
- button.style.color = 'white';
- button.style.display = 'flex';
- button.style.alignItems = 'center';
- button.style.justifyContent = 'center';
- button.style.zIndex = '10';
- button.style.fontSize = '11px';
- button.style.fontWeight = '600';
- button.style.fontFamily = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace';
- button.style.textTransform = 'uppercase';
- button.style.letterSpacing = '0.5px';
-
- // Style the pre element to be relative
- preElement.style.position = 'relative';
-
- // Show button on hover
- preElement.addEventListener('mouseenter', () => {
- button.style.opacity = '1';
- });
-
- preElement.addEventListener('mouseleave', () => {
- button.style.opacity = '0';
- });
-
- // Copy to clipboard on click
- button.addEventListener('click', async (e) => {
- e.preventDefault();
- e.stopPropagation();
-
- const codeElement = preElement.querySelector('code');
- if (!codeElement) return;
-
- const code = codeElement.textContent;
-
- const originalText = button.dataset.originalText;
- const copiedText = this.t('common.copied');
- const copyTitle = this.t('common.copy_to_clipboard');
-
- try {
- await navigator.clipboard.writeText(code);
-
- // Visual feedback - show localized "Copied!"
- button.innerHTML = `${copiedText}`;
- button.style.backgroundColor = 'rgba(34, 197, 94, 0.8)';
- button.title = copiedText;
-
- // Reset after 2 seconds
- setTimeout(() => {
- button.innerHTML = `${originalText}`;
- button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
- button.title = copyTitle;
- }, 2000);
- } catch (err) {
- console.error('Failed to copy code:', err);
-
- // Fallback for older browsers
- const textArea = document.createElement('textarea');
- textArea.value = code;
- textArea.style.position = 'fixed';
- textArea.style.left = '-999999px';
- document.body.appendChild(textArea);
- textArea.select();
-
- try {
- document.execCommand('copy');
- button.innerHTML = `${copiedText}`;
- button.style.backgroundColor = 'rgba(34, 197, 94, 0.8)';
- setTimeout(() => {
- button.innerHTML = `${originalText}`;
- button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
- }, 2000);
- } catch (fallbackErr) {
- console.error('Fallback copy failed:', fallbackErr);
- }
-
- document.body.removeChild(textArea);
- }
- });
-
- // Add button to pre element
- preElement.appendChild(button);
- },
-
- // Setup scroll synchronization
- setupScrollSync() {
- // Use cached references (refresh if not available)
- if (!this._domCache.editor || !this._domCache.previewContainer) {
- this.refreshDOMCache();
- }
-
- const editor = this._domCache.editor;
- const preview = this._domCache.previewContainer;
-
- if (!editor || !preview) {
- // If elements don't exist yet, retry with limit
- if (!this._setupScrollSyncRetries) this._setupScrollSyncRetries = 0;
- if (this._setupScrollSyncRetries < CONFIG.SCROLL_SYNC_MAX_RETRIES) {
- this._setupScrollSyncRetries++;
- setTimeout(() => this.setupScrollSync(), CONFIG.SCROLL_SYNC_RETRY_INTERVAL);
- } else {
- console.warn(`setupScrollSync: Failed to find editor/preview elements after ${CONFIG.SCROLL_SYNC_MAX_RETRIES} retries`);
- }
- return;
- }
-
- // Reset retry counter on success
- this._setupScrollSyncRetries = 0;
-
- // Remove old listeners if they exist
- if (this._editorScrollHandler) {
- editor.removeEventListener('scroll', this._editorScrollHandler);
- }
- if (this._previewScrollHandler) {
- preview.removeEventListener('scroll', this._previewScrollHandler);
- }
-
- // Create new scroll handlers
- this._editorScrollHandler = () => {
- if (this.isScrolling) {
- this.isScrolling = false;
- return;
- }
-
- const scrollableHeight = editor.scrollHeight - editor.clientHeight;
- if (scrollableHeight <= 0) return; // No scrolling needed
-
- const scrollPercentage = editor.scrollTop / scrollableHeight;
- const previewScrollableHeight = preview.scrollHeight - preview.clientHeight;
-
- if (previewScrollableHeight > 0) {
- this.isScrolling = true;
- preview.scrollTop = scrollPercentage * previewScrollableHeight;
- }
- };
-
- this._previewScrollHandler = () => {
- if (this.isScrolling) {
- this.isScrolling = false;
- return;
- }
-
- const scrollableHeight = preview.scrollHeight - preview.clientHeight;
- if (scrollableHeight <= 0) return; // No scrolling needed
-
- const scrollPercentage = preview.scrollTop / scrollableHeight;
- const editorScrollableHeight = editor.scrollHeight - editor.clientHeight;
-
- if (editorScrollableHeight > 0) {
- this.isScrolling = true;
- editor.scrollTop = scrollPercentage * editorScrollableHeight;
- }
- };
-
- // Attach new listeners
- editor.addEventListener('scroll', this._editorScrollHandler);
- preview.addEventListener('scroll', this._previewScrollHandler);
- },
-
- // Check if stats plugin is enabled
- async checkStatsPlugin() {
- try {
- const response = await fetch('/api/plugins');
- const data = await response.json();
- const statsPlugin = data.plugins.find(p => p.id === 'note_stats');
- this.statsPluginEnabled = statsPlugin && statsPlugin.enabled;
-
- // Calculate stats for current note if enabled
- if (this.statsPluginEnabled && this.noteContent) {
- this.calculateStats();
- }
- } catch (error) {
- console.error('Failed to check stats plugin:', error);
- this.statsPluginEnabled = false;
- }
- },
-
- // Calculate note statistics (client-side)
- calculateStats() {
- if (!this.statsPluginEnabled || !this.noteContent) {
- this.noteStats = null;
- return;
- }
-
- const content = this.noteContent;
-
- // Word count
- const words = (content.match(/\S+/g) || []).length;
-
- // Character count
- const chars = content.replace(/\s/g, '').length;
- const totalChars = content.length;
-
- // Reading time (200 words per minute)
- const readingTime = Math.max(1, Math.round(words / 200));
-
- // Line count
- const lines = content.split('\n').length;
-
- // Paragraph count
- const paragraphs = content.split('\n\n').filter(p => p.trim()).length;
-
- // Sentences: punctuation [.!?]+ followed by space or end-of-string
- const sentences = (content.match(/[.!?]+(?:\s|$)/g) || []).length;
-
- // List items: lines starting with -, *, + or a number (e.g. 1., 10.), excluding tasks [-]
- const listItems = (content.match(/^\s*(?:[-*+]|\d+\.)\s+(?!\[)/gm) || []).length;
-
- // Tables: markdown table separator rows (| --- | --- |)
- const tables = (content.match(/^\s*\|(?:\s*:?-+:?\s*\|){1,}\s*$/gm) || []).length;
-
- // Link count (standard markdown links)
- const markdownLinkMatches = content.match(/\[([^\]]+)\]\(([^\)]+)\)/g) || [];
- const markdownLinks = markdownLinkMatches.length;
- const markdownInternalLinks = markdownLinkMatches.filter(l => l.includes('.md')).length;
-
- // Wikilink count ([[note]] or [[note|display text]] format)
- const wikilinks = (content.match(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g) || []).length;
-
- // Total links (markdown + wikilinks)
- const links = markdownLinks + wikilinks;
- const internalLinks = markdownInternalLinks + wikilinks; // All wikilinks are internal
-
- // Code blocks
- const codeBlocks = (content.match(/```[\s\S]*?```/g) || []).length;
- const inlineCode = (content.match(/`[^`]+`/g) || []).length;
-
- // Headings
- const h1 = (content.match(/^# /gm) || []).length;
- const h2 = (content.match(/^## /gm) || []).length;
- const h3 = (content.match(/^### /gm) || []).length;
-
- // Tasks
- const totalTasks = (content.match(/- \[[ x]\]/gi) || []).length;
- const completedTasks = (content.match(/- \[x\]/gi) || []).length;
- const pendingTasks = totalTasks - completedTasks;
- const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
-
- // Images
- const images = (content.match(/!\[([^\]]*)\]\(([^\)]+)\)/g) || []).length;
-
- // Blockquotes
- const blockquotes = (content.match(/^> /gm) || []).length;
-
- this.noteStats = {
- words,
- sentences,
- characters: chars,
- total_characters: totalChars,
- reading_time_minutes: readingTime,
- lines,
- paragraphs,
- list_items: listItems,
- tables,
- links,
- internal_links: internalLinks,
- external_links: links - internalLinks,
- wikilinks,
- code_blocks: codeBlocks,
- inline_code: inlineCode,
- headings: {
- h1,
- h2,
- h3,
- total: h1 + h2 + h3
- },
- tasks: {
- total: totalTasks,
- completed: completedTasks,
- pending: pendingTasks,
- completion_rate: completionRate
- },
- images,
- blockquotes
- };
- },
-
- // Parse YAML frontmatter metadata from note content
- parseMetadata() {
- if (!this.noteContent) {
- this.noteMetadata = null;
- this._lastFrontmatter = null;
- return;
- }
-
- const content = this.noteContent;
-
- // Check if content starts with frontmatter
- if (!content.trim().startsWith('---')) {
- this.noteMetadata = null;
- this._lastFrontmatter = null;
- return;
- }
-
- try {
- const lines = content.split('\n');
- if (lines[0].trim() !== '---') {
- this.noteMetadata = null;
- this._lastFrontmatter = null;
- return;
- }
-
- // Find closing ---
- let endIdx = -1;
- for (let i = 1; i < lines.length; i++) {
- if (lines[i].trim() === '---') {
- endIdx = i;
- break;
- }
- }
-
- if (endIdx === -1) {
- this.noteMetadata = null;
- this._lastFrontmatter = null;
- return;
- }
-
- // Performance optimization: skip parsing if frontmatter unchanged
- const frontmatterRaw = lines.slice(0, endIdx + 1).join('\n');
- if (frontmatterRaw === this._lastFrontmatter) {
- return; // No change, keep existing metadata
- }
- this._lastFrontmatter = frontmatterRaw;
-
- const frontmatterLines = lines.slice(1, endIdx);
- const metadata = {};
- let currentKey = null;
- let currentValue = [];
-
- for (const line of frontmatterLines) {
- // Check for new key: value pair (supports keys with hyphens/underscores)
- const keyMatch = line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/);
-
- if (keyMatch) {
- // Save previous key if exists
- if (currentKey) {
- metadata[currentKey] = this.parseYamlValue(currentValue.join('\n'));
- }
-
- currentKey = keyMatch[1];
- const value = keyMatch[2].trim();
- currentValue = [value];
- } else if (line.match(/^\s+-\s+/) && currentKey) {
- // List item continuation (e.g., " - item")
- currentValue.push(line);
- } else if (line.startsWith(' ') && currentKey) {
- // Indented content (multiline value)
- currentValue.push(line);
- }
- }
-
- // Save last key
- if (currentKey) {
- metadata[currentKey] = this.parseYamlValue(currentValue.join('\n'));
- }
-
- this.noteMetadata = Object.keys(metadata).length > 0 ? metadata : null;
-
- } catch (error) {
- console.error('Failed to parse frontmatter:', error);
- this.noteMetadata = null;
- this._lastFrontmatter = null;
- }
- },
-
- // Parse a YAML value (handles arrays, strings, numbers, booleans)
- parseYamlValue(value) {
- if (!value || value.trim() === '') return null;
-
- value = value.trim();
-
- // Check for inline array: [item1, item2]
- if (value.startsWith('[') && value.endsWith(']')) {
- const inner = value.slice(1, -1);
- return inner.split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(s => s);
- }
-
- // Check for YAML list format (multiple lines starting with -)
- if (value.includes('\n -') || value.startsWith(' -')) {
- const items = [];
- const lines = value.split('\n');
- for (const line of lines) {
- const match = line.match(/^\s*-\s*(.+)$/);
- if (match) {
- items.push(match[1].trim().replace(/^["']|["']$/g, ''));
- }
- }
- return items.length > 0 ? items : value;
- }
-
- // Check for boolean
- if (value.toLowerCase() === 'true') return true;
- if (value.toLowerCase() === 'false') return false;
-
- // Check for number
- if (/^-?\d+(\.\d+)?$/.test(value)) {
- return parseFloat(value);
- }
-
- // Return as string (remove quotes if present)
- return value.replace(/^["']|["']$/g, '');
- },
-
- // Check if a string is a URL
- isUrl(str) {
- if (typeof str !== 'string') return false;
- return /^https?:\/\/\S+$/i.test(str.trim());
- },
-
- // Escape HTML to prevent XSS
- escapeHtml(str) {
- const div = document.createElement('div');
- div.textContent = str;
- return div.innerHTML;
- },
-
- // Format metadata value for display
- formatMetadataValue(key, value) {
- if (value === null || value === undefined) return '';
-
- // Arrays are handled separately in the template
- if (Array.isArray(value)) return value;
-
- // Format dates nicely
- if (key === 'date' || key === 'created' || key === 'modified' || key === 'updated') {
- let date;
- // Parse date-only strings (YYYY-MM-DD) as local dates to avoid timezone issues
- if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
- const [year, month, day] = value.split('-').map(Number);
- date = new Date(year, month - 1, day); // month is 0-indexed
- } else {
- date = new Date(value);
- }
- if (!isNaN(date.getTime())) {
- return date.toLocaleDateString(this.currentLocale, {
- year: 'numeric',
- month: 'short',
- day: 'numeric'
- });
- }
- }
-
- // Booleans
- if (typeof value === 'boolean') {
- return value ? this.t('common.yes') : this.t('common.no');
- }
-
- return String(value);
- },
-
- // Format metadata value as HTML (for URL support)
- formatMetadataValueHtml(key, value) {
- const formatted = this.formatMetadataValue(key, value);
-
- // Check if it's a URL
- if (this.isUrl(formatted)) {
- const escaped = this.escapeHtml(formatted);
- // Truncate long URLs for display
- const displayUrl = formatted.length > 40
- ? formatted.substring(0, 37) + '...'
- : formatted;
- return `${this.escapeHtml(displayUrl)}`;
- }
-
- return this.escapeHtml(formatted);
- },
-
- // Get priority metadata fields (shown in collapsed view)
- getPriorityMetadataFields() {
- if (!this.noteMetadata) return [];
-
- // Fields to show in collapsed view, in order of priority
- const priority = ['date', 'created', 'author', 'status', 'priority', 'type', 'category'];
- const fields = [];
-
- for (const key of priority) {
- if (this.noteMetadata[key] !== undefined && !Array.isArray(this.noteMetadata[key])) {
- const formatted = this.formatMetadataValue(key, this.noteMetadata[key]);
- const isUrl = this.isUrl(formatted);
- fields.push({
- key,
- value: formatted,
- valueHtml: isUrl ? this.formatMetadataValueHtml(key, this.noteMetadata[key]) : this.escapeHtml(formatted),
- isUrl
- });
- }
- }
-
- return fields.slice(0, 3); // Show max 3 fields in collapsed view
- },
-
- // Get all metadata fields except tags (for expanded view)
- getAllMetadataFields() {
- if (!this.noteMetadata) return [];
-
- return Object.entries(this.noteMetadata)
- .filter(([key]) => key !== 'tags') // Tags shown separately
- .map(([key, value]) => {
- const isArray = Array.isArray(value);
- const formatted = this.formatMetadataValue(key, value);
- const isUrl = !isArray && this.isUrl(formatted);
- return {
- key,
- value: formatted,
- valueHtml: isUrl ? this.formatMetadataValueHtml(key, value) : this.escapeHtml(formatted),
- isArray,
- isUrl
- };
- });
- },
-
- // Check if note has any displayable metadata
- getHasMetadata() {
- const has = this.noteMetadata && Object.keys(this.noteMetadata).length > 0;
- return has;
- },
-
- // Get tags from metadata
- getMetadataTags() {
- if (!this.noteMetadata || !this.noteMetadata.tags) return [];
- return Array.isArray(this.noteMetadata.tags) ? this.noteMetadata.tags : [this.noteMetadata.tags];
- },
-
- // Save sidebar width to localStorage
- saveSidebarWidth() {
- localStorage.setItem('sidebarWidth', this.sidebarWidth.toString());
- },
-
- // Save view mode to localStorage
- saveViewMode() {
- try {
- localStorage.setItem('viewMode', this.viewMode);
- } catch (error) {
- console.error('Error saving view mode:', error);
- }
- },
-
- saveTagsExpanded() {
- try {
- localStorage.setItem('tagsExpanded', this.tagsExpanded.toString());
- } catch (error) {
- console.error('Error saving tags expanded state:', error);
- }
- },
-
- // Start resizing sidebar
- startResize(event) {
- this.isResizing = true;
- event.preventDefault();
-
- const resize = (e) => {
- if (!this.isResizing) return;
-
- // Calculate new width based on mouse position
- const newWidth = e.clientX;
-
- // Clamp between min and max
- if (newWidth >= 200 && newWidth <= 600) {
- this.sidebarWidth = newWidth;
- }
- };
-
- const stopResize = () => {
- if (this.isResizing) {
- this.isResizing = false;
- this.saveSidebarWidth();
- document.removeEventListener('mousemove', resize);
- document.removeEventListener('mouseup', stopResize);
- }
- };
-
- document.addEventListener('mousemove', resize);
- document.addEventListener('mouseup', stopResize);
- },
-
- // Start resizing split panes (editor/preview)
- startSplitResize(event) {
- this.isResizingSplit = true;
- event.preventDefault();
-
- const container = event.target.parentElement;
-
- const resize = (e) => {
- if (!this.isResizingSplit) return;
-
- const containerRect = container.getBoundingClientRect();
- const mouseX = e.clientX - containerRect.left;
- const percentage = (mouseX / containerRect.width) * 100;
-
- // Clamp between 20% and 80%
- if (percentage >= 20 && percentage <= 80) {
- this.editorWidth = percentage;
- }
- };
-
- const stopResize = () => {
- if (this.isResizingSplit) {
- this.isResizingSplit = false;
- this.saveEditorWidth();
- document.removeEventListener('mousemove', resize);
- document.removeEventListener('mouseup', stopResize);
- }
- };
-
- document.addEventListener('mousemove', resize);
- document.addEventListener('mouseup', stopResize);
- },
-
- // Setup mobile view mode handler (auto-switch from split to edit on mobile)
- setupMobileViewMode() {
- const MOBILE_BREAKPOINT = 768; // Match CSS breakpoint
- let previousWidth = window.innerWidth;
-
- const handleResize = () => {
- const currentWidth = window.innerWidth;
- const wasMobile = previousWidth <= MOBILE_BREAKPOINT;
- const isMobile = currentWidth <= MOBILE_BREAKPOINT;
-
- // If switching from desktop to mobile and in split mode
- if (!wasMobile && isMobile && this.viewMode === 'split') {
- this.viewMode = 'edit';
- }
-
- previousWidth = currentWidth;
- };
-
- // Listen for window resize
- window.addEventListener('resize', handleResize);
-
- // Check initial state
- if (window.innerWidth <= MOBILE_BREAKPOINT && this.viewMode === 'split') {
- this.viewMode = 'edit';
- }
- },
-
- // Save editor width to localStorage
- saveEditorWidth() {
- localStorage.setItem('editorWidth', this.editorWidth.toString());
- },
-
- // Scroll to top of editor and preview
- scrollToTop() {
- // Disable scroll sync temporarily to prevent interference
- this.isScrolling = true;
-
- // Use cached references (refresh if not available)
- if (!this._domCache.editor || !this._domCache.previewContainer) {
- this.refreshDOMCache();
- }
-
- // Only scroll the visible panes based on viewMode
- if (this.viewMode === 'edit' || this.viewMode === 'split') {
- if (this._domCache.editor) {
- this._domCache.editor.scrollTop = 0;
- }
- }
-
- if (this.viewMode === 'preview' || this.viewMode === 'split') {
- // Scroll the preview container (parent of .markdown-preview)
- if (this._domCache.previewContainer) {
- this._domCache.previewContainer.scrollTop = 0;
- }
- }
-
- // Re-enable scroll sync after a short delay
- setTimeout(() => {
- this.isScrolling = false;
- }, CONFIG.SCROLL_SYNC_DELAY);
- },
-
- // Export current note as HTML via backend API
- async exportToHTML() {
- if (!this.currentNote || !this.noteContent) {
- alert(this.t('notes.no_content'));
- return;
- }
-
- try {
- // Build API URL with current theme
- const currentTheme = this.currentTheme || 'light';
- const encodedPath = this.currentNote.split('/').map(s => encodeURIComponent(s)).join('/');
- const url = `/api/export/${encodedPath}?theme=${encodeURIComponent(currentTheme)}`;
-
- // Fetch the exported HTML from backend
- const response = await fetch(url);
- if (!response.ok) {
- const error = await response.json().catch(() => ({ detail: 'Export failed' }));
- throw new Error(error.detail || 'Export failed');
- }
-
- // Get filename from Content-Disposition header or use note name
- let filename = (this.currentNoteName || 'note') + '.html';
- const contentDisposition = response.headers.get('Content-Disposition');
- if (contentDisposition) {
- const match = contentDisposition.match(/filename="([^"]+)"/);
- if (match) {
- filename = match[1];
- }
- }
-
- // Download as blob
- const blob = await response.blob();
- const blobUrl = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = blobUrl;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
-
- // Cleanup
- URL.revokeObjectURL(blobUrl);
- document.body.removeChild(a);
-
- } catch (error) {
- console.error('HTML export failed:', error);
- alert(this.t('export.failed', { error: error.message }));
- }
- },
-
- // Open print preview in new window
- printPreview() {
- if (!this.currentNote || !this.noteContent) {
- alert(this.t('notes.no_content'));
- return;
- }
-
- // Build API URL with current theme and download=false for inline display
- const currentTheme = this.currentTheme || 'light';
- const encodedPath = this.currentNote.split('/').map(s => encodeURIComponent(s)).join('/');
- const url = `/api/export/${encodedPath}?theme=${encodeURIComponent(currentTheme)}&download=false`;
-
- // Open in new window/tab
- window.open(url, '_blank');
- },
-
- // Copy current note link to clipboard
- async copyNoteLink() {
- if (!this.currentNote) return;
-
- // Build the full URL
- const pathWithoutExtension = this.currentNote.replace('.md', '');
- const encodedPath = pathWithoutExtension.split('/').map(segment => encodeURIComponent(segment)).join('/');
- const url = `${window.location.origin}/${encodedPath}`;
-
- try {
- await navigator.clipboard.writeText(url);
- } catch (error) {
- // Fallback for older browsers
- const textArea = document.createElement('textarea');
- textArea.value = url;
- document.body.appendChild(textArea);
- textArea.select();
- document.execCommand('copy');
- document.body.removeChild(textArea);
- }
-
- // Show brief "Copied!" feedback
- this.linkCopied = true;
- setTimeout(() => {
- this.linkCopied = false;
- }, 1500);
- },
-
- // ============================================================================
- // Share Functions
- // ============================================================================
-
- // Load list of shared note paths (for visual indicators)
- async loadSharedNotePaths() {
- try {
- const response = await fetch('/api/shared-notes');
- if (response.ok) {
- const data = await response.json();
- this._sharedNotePaths = new Set(data.paths || []);
- }
- } catch (error) {
- console.error('Failed to load shared note paths:', error);
- this._sharedNotePaths = new Set();
- }
- },
-
- // Check if a note is currently shared (O(1) lookup)
- isNoteShared(notePath) {
- return this._sharedNotePaths.has(notePath);
- },
-
- // ============================================
- // Quick Switcher (Ctrl+Alt+P)
- // ============================================
-
- openQuickSwitcher() {
- this.showQuickSwitcher = true;
- this.quickSwitcherQuery = '';
- this.quickSwitcherIndex = 0;
- // Populate initial results
- this.quickSwitcherResults = (this.allNotes || []).slice(0, 10);
- // Focus the input after the modal renders
- this.$nextTick(() => {
- const input = document.getElementById('quickSwitcherInput');
- if (input) input.focus();
- });
- },
-
- closeQuickSwitcher() {
- this.showQuickSwitcher = false;
- this.quickSwitcherQuery = '';
- this.quickSwitcherIndex = 0;
- },
-
- // Filter notes for quick switcher based on query
- filterQuickSwitcher(query) {
- // Only include actual notes, not images
- const notes = (this.notes || []).filter(n => n.type === 'note');
- if (!query || !query.trim()) {
- // Show recent notes when no query
- return notes.slice(0, 10);
- }
- const q = query.toLowerCase();
- return notes
- .filter(n =>
- n.name.toLowerCase().includes(q) ||
- n.path.toLowerCase().includes(q)
- )
- .slice(0, 10);
- },
-
- // Handle keyboard navigation in quick switcher
- handleQuickSwitcherKeydown(e) {
- const results = this.quickSwitcherResults;
-
- if (e.key === 'ArrowDown') {
- e.preventDefault();
- this.quickSwitcherIndex = Math.min(this.quickSwitcherIndex + 1, results.length - 1);
- this.scrollQuickSwitcherIntoView();
- } else if (e.key === 'ArrowUp') {
- e.preventDefault();
- this.quickSwitcherIndex = Math.max(this.quickSwitcherIndex - 1, 0);
- this.scrollQuickSwitcherIntoView();
- } else if (e.key === 'Enter') {
- e.preventDefault();
- const note = results[this.quickSwitcherIndex];
- if (note) {
- this.loadNote(note.path);
- this.closeQuickSwitcher();
- }
- } else if (e.key === 'Escape') {
- e.preventDefault();
- this.closeQuickSwitcher();
- }
- },
-
- // Scroll selected item into view in quick switcher
- scrollQuickSwitcherIntoView() {
- this.$nextTick(() => {
- const items = document.querySelectorAll('[data-quick-switcher-item]');
- if (items[this.quickSwitcherIndex]) {
- items[this.quickSwitcherIndex].scrollIntoView({ block: 'nearest' });
- }
- });
- },
-
- // Select note from quick switcher by click
- selectQuickSwitcherNote(note) {
- this.loadNote(note.path);
- this.closeQuickSwitcher();
- },
-
- // Close share modal and reset state after animation
- closeShareModal() {
- this.showShareModal = false;
- // Delay state reset until modal is fully hidden
- setTimeout(() => {
- this.showShareQR = false;
- this.shareInfo = null;
- this.shareLoading = false;
- }, 200);
- },
-
- // Generate QR code for share URL
- generateQRCode(url) {
- if (!url || typeof qrcode === 'undefined') return '';
- try {
- const qr = qrcode(0, 'M'); // 0 = auto version, M = medium error correction
- qr.addData(url);
- qr.make();
- return qr.createDataURL(4); // 4 = module size in pixels
- } catch (e) {
- console.error('QR code generation failed:', e);
- return '';
- }
- },
-
- // Open share modal and fetch current share status
- async openShareModal() {
- if (!this.currentNote) return;
-
- // Reset state BEFORE showing modal to prevent flicker
- this.showShareQR = false;
- this.shareInfo = null;
- this.shareLoading = true;
- this.showShareModal = true;
-
- try {
- const notePath = this.currentNote.replace('.md', '');
- const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
- const response = await fetch(`/api/share/${encodedPath}`);
-
- if (response.ok) {
- this.shareInfo = await response.json();
- } else {
- this.shareInfo = { shared: false };
- }
- } catch (error) {
- console.error('Failed to get share status:', error);
- this.shareInfo = { shared: false };
- } finally {
- this.shareLoading = false;
- }
- },
-
- // Create a share link for the current note (with current theme)
- async createShareLink() {
- if (!this.currentNote) return;
-
- this.shareLoading = true;
-
- try {
- const notePath = this.currentNote.replace('.md', '');
- const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
- const response = await fetch(`/api/share/${encodedPath}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ theme: this.currentTheme || 'light' })
- });
-
- if (response.ok) {
- this.shareInfo = await response.json();
- this.shareInfo.shared = true;
- // Update the shared paths set
- this._sharedNotePaths.add(this.currentNote);
- } else {
- const error = await response.json();
- alert(this.t('share.error_creating', { error: error.detail || 'Unknown error' }));
- }
- } catch (error) {
- console.error('Failed to create share link:', error);
- alert(this.t('share.error_creating', { error: error.message }));
- } finally {
- this.shareLoading = false;
- }
- },
-
- // Copy share link to clipboard
- async copyShareLink() {
- if (!this.shareInfo?.url) return;
-
- try {
- await navigator.clipboard.writeText(this.shareInfo.url);
- } catch (error) {
- // Fallback for older browsers
- const textArea = document.createElement('textarea');
- textArea.value = this.shareInfo.url;
- document.body.appendChild(textArea);
- textArea.select();
- document.execCommand('copy');
- document.body.removeChild(textArea);
- }
-
- this.shareLinkCopied = true;
- setTimeout(() => {
- this.shareLinkCopied = false;
- }, 2000);
- },
-
- // Revoke share link
- async revokeShareLink() {
- if (!this.currentNote) return;
-
- if (!confirm(this.t('share.confirm_revoke'))) return;
-
- this.shareLoading = true;
-
- try {
- const notePath = this.currentNote.replace('.md', '');
- const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
- const response = await fetch(`/api/share/${encodedPath}`, {
- method: 'DELETE'
- });
-
- if (response.ok) {
- this.shareInfo = { shared: false };
- // Update the shared paths set
- this._sharedNotePaths.delete(this.currentNote);
- } else {
- const error = await response.json();
- alert(this.t('share.error_revoking', { error: error.detail || 'Unknown error' }));
- }
- } catch (error) {
- console.error('Failed to revoke share link:', error);
- alert(this.t('share.error_revoking', { error: error.message }));
- } finally {
- this.shareLoading = false;
- }
- },
-
- // Toggle Zen Mode (full immersive writing experience)
- async toggleZenMode() {
- if (!this.zenMode) {
- // Entering Zen Mode
- this.previousViewMode = this.viewMode;
- this.viewMode = 'edit';
- this.mobileSidebarOpen = false;
- this.zenMode = true;
-
- // Request fullscreen
- try {
- const elem = document.documentElement;
- if (elem.requestFullscreen) {
- await elem.requestFullscreen();
- } else if (elem.webkitRequestFullscreen) {
- await elem.webkitRequestFullscreen();
- } else if (elem.msRequestFullscreen) {
- await elem.msRequestFullscreen();
- }
- } catch (e) {
- // Fullscreen not supported or denied, continue anyway
- console.log('Fullscreen not available:', e);
- }
-
- // Focus editor after transition
- setTimeout(() => {
- const editor = document.getElementById('note-editor');
- if (editor) editor.focus();
- }, 300);
- } else {
- // Exiting Zen Mode
- this.zenMode = false;
- this.viewMode = this.previousViewMode;
-
- // Exit fullscreen
- try {
- if (document.exitFullscreen) {
- await document.exitFullscreen();
- } else if (document.webkitExitFullscreen) {
- await document.webkitExitFullscreen();
- } else if (document.msExitFullscreen) {
- await document.msExitFullscreen();
- }
- } catch (e) {
- console.log('Exit fullscreen error:', e);
- }
- }
- },
-
- // Homepage folder navigation methods
- goToHomepageFolder(folderPath) {
- this.showGraph = false; // Close graph when navigating
- this.selectedHomepageFolder = folderPath || '';
-
- // Clear editor state to show landing page
- this.currentNote = '';
- this.currentNoteName = '';
- this.noteContent = '';
- this.currentMedia = '';
- this.outline = [];
- this.backlinks = [];
- document.title = this.appName;
-
- // Invalidate cache to force recalculation
- this._homepageCache = {
- folderPath: null,
- notes: null,
- folders: null,
- breadcrumb: null
- };
-
- window.history.pushState({ homepageFolder: folderPath || '' }, '', '/');
- },
-
- // Navigate to homepage root and clear all editor state
- goHome() {
- this.showGraph = false; // Close graph when going home
- this.selectedHomepageFolder = '';
- this.currentNote = '';
- this.currentNoteName = '';
- this.noteContent = '';
- this.currentMedia = '';
- this.outline = [];
- this.backlinks = [];
- this.mobileSidebarOpen = false;
- document.title = this.appName;
-
- // Clear undo/redo history
- this.undoHistory = [];
- this.redoHistory = [];
- this.hasPendingHistoryChanges = false;
-
- // Invalidate cache to force recalculation
- this._homepageCache = {
- folderPath: null,
- notes: null,
- folders: null,
- breadcrumb: null
- };
-
- window.history.pushState({ homepageFolder: '' }, '', '/');
- },
-
- // Mobile files/home tab - context-aware behavior
- mobileFilesTabClick() {
- if (this.currentNote || this.currentMedia || this.showGraph) {
- // Viewing content โ go home
- this.goHome();
- } else {
- // On homepage โ toggle files sidebar
- this.activePanel = 'files';
- this.mobileSidebarOpen = !this.mobileSidebarOpen;
- }
- },
-
- // ==================== GRAPH VIEW ====================
-
- // Initialize the graph visualization
- async initGraph() {
- // Check if vis is loaded
- if (typeof vis === 'undefined') {
- console.error('vis-network library not loaded');
- return;
- }
-
- this.graphLoaded = false;
-
- try {
- // Fetch graph data from API
- const response = await fetch('/api/graph');
- if (!response.ok) throw new Error('Failed to fetch graph data');
- const data = await response.json();
- this.graphData = data;
-
- // Get container
- const container = document.getElementById('graph-overlay');
- if (!container) return;
-
- // Get theme colors (force reflow to ensure CSS is applied)
- document.body.offsetHeight; // Force reflow
- const style = getComputedStyle(document.documentElement);
-
- // Helper to get CSS variable with fallback
- const getCssVar = (name, fallback) => {
- const value = style.getPropertyValue(name).trim();
- return value || fallback;
- };
-
- const accentPrimary = getCssVar('--accent-primary', '#7c3aed');
- const accentSecondary = getCssVar('--accent-secondary', '#a78bfa');
- const textPrimary = getCssVar('--text-primary', '#111827');
- const textSecondary = getCssVar('--text-secondary', '#6b7280');
- const bgPrimary = getCssVar('--bg-primary', '#ffffff');
- const bgSecondary = getCssVar('--bg-secondary', '#f3f4f6');
- const borderColor = getCssVar('--border-primary', '#e5e7eb');
-
- // Prepare nodes with styling - all nodes same base color
- const nodes = new vis.DataSet(data.nodes.map(n => ({
- id: n.id,
- label: n.label,
- title: n.id, // Tooltip shows full path
- color: {
- background: accentPrimary,
- border: accentPrimary,
- highlight: {
- background: accentPrimary,
- border: textPrimary // Darker border when selected
- },
- hover: {
- background: accentSecondary,
- border: accentPrimary
- }
- },
- font: {
- color: textPrimary,
- size: 12,
- face: 'system-ui, -apple-system, sans-serif'
- },
- borderWidth: this.currentNote === n.id ? 4 : 2,
- chosen: {
- node: (values) => {
- values.size = 22;
- values.borderWidth = 4;
- values.borderColor = textPrimary;
- }
- }
- })));
-
- // Prepare edges with styling based on type
- const edges = new vis.DataSet(data.edges.map((e, i) => ({
- id: i,
- from: e.source,
- to: e.target,
- color: {
- color: e.type === 'wikilink' ? accentPrimary : borderColor,
- highlight: accentPrimary,
- hover: accentSecondary,
- opacity: 0.8
- },
- width: e.type === 'wikilink' ? 2 : 1,
- smooth: {
- type: 'continuous',
- roundness: 0.5
- },
- chosen: {
- edge: (values) => {
- values.width = 3;
- values.color = accentPrimary;
- }
- }
- })));
-
- // Network options
- const options = {
- nodes: {
- shape: 'dot',
- size: 16,
- borderWidth: 2,
- shadow: {
- enabled: true,
- color: 'rgba(0,0,0,0.1)',
- size: 5,
- x: 2,
- y: 2
- }
- },
- edges: {
- arrows: {
- to: {
- enabled: true,
- scaleFactor: 0.5,
- type: 'arrow'
- }
- }
- },
- physics: {
- enabled: true,
- solver: 'forceAtlas2Based',
- forceAtlas2Based: {
- gravitationalConstant: -50,
- centralGravity: 0.01,
- springLength: 100,
- springConstant: 0.08,
- damping: 0.4,
- avoidOverlap: 0.5
- },
- stabilization: {
- enabled: true,
- iterations: 200,
- updateInterval: 25
- }
- },
- interaction: {
- hover: true,
- tooltipDelay: 200,
- navigationButtons: false, // Using custom buttons instead
- keyboard: {
- enabled: true,
- bindToWindow: false
- },
- zoomView: true,
- dragView: true
- },
- layout: {
- improvedLayout: true,
- randomSeed: 42
- }
- };
-
- // Destroy existing instance if any
- if (this.graphInstance) {
- this.graphInstance.destroy();
- this.graphInstance = null;
- }
-
- // Clear container to ensure clean state
- const graphCanvas = container.querySelector('canvas');
- if (graphCanvas) graphCanvas.remove();
- const visElements = container.querySelectorAll('.vis-network, .vis-navigation');
- visElements.forEach(el => el.remove());
-
- // Create the network
- this.graphInstance = new vis.Network(container, { nodes, edges }, options);
-
- // Store reference for callbacks
- const graphRef = this.graphInstance;
- const currentNoteRef = this.currentNote;
-
- // Wait for stabilization
- this.graphInstance.once('stabilizationIterationsDone', () => {
- graphRef.setOptions({ physics: { enabled: false } });
- this.graphLoaded = true;
-
- // Focus and select current note if one is loaded
- if (currentNoteRef) {
- setTimeout(() => {
- try {
- if (graphRef && this.showGraph) {
- const nodeIds = graphRef.body.data.nodes.getIds();
- if (nodeIds.includes(currentNoteRef)) {
- // Focus on the node
- graphRef.focus(currentNoteRef, {
- scale: 1.2,
- animation: {
- duration: 500,
- easingFunction: 'easeInOutQuad'
- }
- });
- // Select the node to highlight it
- graphRef.selectNodes([currentNoteRef]);
- }
- }
- } catch (e) {
- // Ignore - graph may have been destroyed
- }
- }, 150);
- }
- });
-
- // Click event - open note
- this.graphInstance.on('click', (params) => {
- if (params.nodes.length > 0) {
- const noteId = params.nodes[0];
- this.loadNote(noteId);
- // Node is already selected by vis-network on click, no need to call selectNodes
- }
- });
-
- // Double-click event - open note and close graph
- this.graphInstance.on('doubleClick', (params) => {
- if (params.nodes.length > 0) {
- const noteId = params.nodes[0];
- // Close graph and load note
- this.showGraph = false;
- this.loadNote(noteId);
- }
- });
-
- // Hover event - highlight connections
- this.graphInstance.on('hoverNode', (params) => {
- const nodeId = params.node;
- const connectedNodes = this.graphInstance.getConnectedNodes(nodeId);
- const connectedEdges = this.graphInstance.getConnectedEdges(nodeId);
-
- // Dim all nodes except hovered and connected
- const allNodes = nodes.getIds();
- const updates = allNodes.map(id => ({
- id,
- opacity: (id === nodeId || connectedNodes.includes(id)) ? 1 : 0.2
- }));
- nodes.update(updates);
- });
-
- this.graphInstance.on('blurNode', () => {
- // Reset all nodes to full opacity
- const allNodes = nodes.getIds();
- const updates = allNodes.map(id => ({ id, opacity: 1 }));
- nodes.update(updates);
- });
-
- // Add legend to container
- this.addGraphLegend(container, accentPrimary, borderColor, textSecondary);
-
- } catch (error) {
- console.error('Failed to initialize graph:', error);
- this.graphLoaded = true; // Stop loading indicator
- }
- },
-
- // Add legend to graph container
- addGraphLegend(container, wikiColor, mdColor, textColor) {
- // Remove existing legend if any
- const existingLegend = container.querySelector('.graph-legend');
- if (existingLegend) existingLegend.remove();
-
- const legend = document.createElement('div');
- legend.className = 'graph-legend';
- legend.innerHTML = `
-
-
- Wikilinks
-
-
-
- ${this.t('graph.markdown_links')}
-
-
- ${this.t('graph.click_hint')}
-
- `;
- container.appendChild(legend);
- },
-
- // Refresh graph when theme changes
- refreshGraph() {
- if (this.viewMode === 'graph' && this.graphInstance) {
- this.initGraph();
- }
- }
- }
-}
-
+// NoteDiscovery Frontend Application
+
+// Configuration constants
+const CONFIG = {
+ AUTOSAVE_DELAY: 1000, // ms - Delay before triggering autosave
+ SEARCH_DEBOUNCE_DELAY: 500, // ms - Delay before running note search while typing
+ SAVE_INDICATOR_DURATION: 2000, // ms - How long to show "saved" indicator
+ SCROLL_SYNC_DELAY: 50, // ms - Delay to prevent scroll sync interference
+ SCROLL_SYNC_MAX_RETRIES: 10, // Maximum attempts to find editor/preview elements
+ SCROLL_SYNC_RETRY_INTERVAL: 100, // ms - Time between setupScrollSync retries
+ MAX_UNDO_HISTORY: 50, // Maximum number of undo steps to keep
+ DEFAULT_SIDEBAR_WIDTH: 256, // px - Default sidebar width (w-64 in Tailwind)
+};
+
+// localStorage settings configuration - centralized definition of all persisted settings
+const LOCAL_SETTINGS = {
+ // Boolean settings
+ syntaxHighlightEnabled: { key: 'syntaxHighlightEnabled', type: 'boolean', default: false },
+ readableLineLength: { key: 'readableLineLength', type: 'boolean', default: true },
+ favoritesExpanded: { key: 'favoritesExpanded', type: 'boolean', default: true },
+ tagsExpanded: { key: 'tagsExpanded', type: 'boolean', default: false },
+ hideUnderscoreFolders: { key: 'hideUnderscoreFolders', type: 'boolean', default: false },
+ tabInsertsTab: { key: 'tabInsertsTab', type: 'boolean', default: false },
+ // Number settings with validation
+ sidebarWidth: { key: 'sidebarWidth', type: 'number', default: CONFIG.DEFAULT_SIDEBAR_WIDTH, min: 200, max: 600 },
+ editorWidth: { key: 'editorWidth', type: 'number', default: 50, min: 20, max: 80 },
+ // String settings with validation
+ viewMode: { key: 'viewMode', type: 'string', default: 'split', valid: ['edit', 'split', 'preview'] },
+ // JSON settings
+ favorites: { key: 'noteFavorites', type: 'json', default: [] },
+};
+
+// Centralized error handling
+const ErrorHandler = {
+ handle(operation, error, showAlert = true) {
+ console.error(`Failed to ${operation}:`, error);
+
+ if (!showAlert) return;
+
+ if (error?.status === 401) {
+ alert("๐ Read-only mode: You need to log in to edit or save notes.");
+ return;
+ }
+
+ alert(`Failed to ${operation}. Please try again.`);
+ }
+};
+
+/**
+ * Centralized filename validation
+ * Supports Unicode characters (international text) but blocks dangerous filesystem characters.
+ * Does NOT silently modify filenames - validates and returns status.
+ */
+const FilenameValidator = {
+ // Characters that are forbidden in filenames across Windows/macOS/Linux
+ // Windows: \ / : * ? " < > |
+ // macOS: / :
+ // Linux: / \0
+ // Common set to block (including control characters)
+ FORBIDDEN_CHARS: /[\\/:*?"<>|\x00-\x1f]/,
+
+ // For display purposes - human readable list
+ FORBIDDEN_CHARS_DISPLAY: '\\ / : * ? " < > |',
+
+ /**
+ * Validate a filename (single segment, no path separators)
+ * @param {string} name - The filename to validate
+ * @returns {{ valid: boolean, error?: string, sanitized?: string }}
+ */
+ validateFilename(name) {
+ if (!name || typeof name !== 'string') {
+ return { valid: false, error: 'empty' };
+ }
+
+ const trimmed = name.trim();
+ if (!trimmed) {
+ return { valid: false, error: 'empty' };
+ }
+
+ // Check for forbidden characters
+ if (this.FORBIDDEN_CHARS.test(trimmed)) {
+ return {
+ valid: false,
+ error: 'forbidden_chars',
+ forbiddenChars: this.FORBIDDEN_CHARS_DISPLAY
+ };
+ }
+
+ // Check for reserved Windows names (case-insensitive)
+ const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
+ if (reservedNames.test(trimmed)) {
+ return { valid: false, error: 'reserved_name' };
+ }
+
+ // Check for names starting/ending with dots or spaces (problematic on some systems)
+ if (trimmed.startsWith('.') && trimmed.length === 1) {
+ return { valid: false, error: 'invalid_dot' };
+ }
+ if (trimmed.endsWith('.') || trimmed.endsWith(' ')) {
+ return { valid: false, error: 'trailing_dot_space' };
+ }
+
+ return { valid: true, sanitized: trimmed };
+ },
+
+ /**
+ * Validate a path (may contain forward slashes for folder separators)
+ * @param {string} path - The path to validate
+ * @returns {{ valid: boolean, error?: string, sanitized?: string }}
+ */
+ validatePath(path) {
+ if (!path || typeof path !== 'string') {
+ return { valid: false, error: 'empty' };
+ }
+
+ const trimmed = path.trim();
+ if (!trimmed) {
+ return { valid: false, error: 'empty' };
+ }
+
+ // Split by forward slash and validate each segment
+ const segments = trimmed.split('/').filter(s => s.length > 0);
+ if (segments.length === 0) {
+ return { valid: false, error: 'empty' };
+ }
+
+ for (const segment of segments) {
+ const result = this.validateFilename(segment);
+ if (!result.valid) {
+ return result;
+ }
+ }
+
+ // Rebuild path without empty segments
+ return { valid: true, sanitized: segments.join('/') };
+ }
+};
+
+function noteApp() {
+ return {
+ // App state
+ appName: 'NoteDiscovery',
+ appVersion: '0.0.0',
+ authEnabled: false,
+ authenticated: false,
+ demoMode: false,
+ alreadyDonated: false,
+ notes: [],
+ currentNote: '',
+ currentNoteName: '',
+ noteContent: '',
+ viewMode: 'split', // 'edit', 'split', 'preview'
+ searchQuery: '',
+
+ // Graph state (separate overlay, doesn't affect viewMode)
+ showGraph: false,
+ graphInstance: null,
+ graphLoaded: false,
+ graphData: null,
+ searchResults: [],
+ currentSearchHighlight: '', // Track current highlighted search term
+ currentMatchIndex: 0, // Current match being viewed
+ totalMatches: 0, // Total number of matches in the note
+ isSaving: false,
+ lastSaved: false,
+ linkCopied: false,
+ zenMode: false,
+ previousViewMode: 'split',
+ favorites: [],
+ favoritesSet: new Set(), // For O(1) lookups
+ favoritesExpanded: true,
+ saveTimeout: null,
+
+ // Note lookup maps for O(1) wikilink resolution (built on loadNotes)
+ _noteLookup: {
+ byPath: new Map(), // path -> true
+ byPathLower: new Map(), // path.toLowerCase() -> true
+ byName: new Map(), // name (without .md) -> true
+ byNameLower: new Map(), // name.toLowerCase() -> true
+ byEndPath: new Map(), // '/filename' and '/filename.md' -> true
+ },
+
+ // Media lookup map for O(1) media wikilink resolution (built on loadNotes)
+ // Maps media filename (case-insensitive) -> full path
+ _mediaLookup: new Map(),
+
+ // Preview rendering debounce
+ _previewDebounceTimeout: null,
+ _lastRenderedContent: '',
+ _cachedRenderedHTML: '',
+ _mathDebounceTimeout: null,
+ _mermaidDebounceTimeout: null,
+
+ // Theme state
+ currentTheme: 'light',
+ availableThemes: [],
+
+ // Locale/i18n state
+ currentLocale: localStorage.getItem('locale') || 'en-US',
+ availableLocales: [],
+ // Translations loaded from backend (preloaded before Alpine init via window.__preloadedTranslations)
+ translations: window.__preloadedTranslations || {},
+
+ // Syntax highlighting
+ syntaxHighlightEnabled: false,
+ syntaxHighlightTimeout: null,
+
+ // Readable line length (preview max-width)
+ readableLineLength: true,
+
+ // Hide underscore-prefixed folders (_attachments, _templates) from sidebar
+ // Read synchronously to prevent flash on initial render
+ hideUnderscoreFolders: localStorage.getItem('hideUnderscoreFolders') === 'true',
+
+ // Tab key inserts tab character instead of changing focus
+ tabInsertsTab: localStorage.getItem('tabInsertsTab') === 'true',
+
+ // Icon rail / panel state
+ activePanel: 'files', // 'files', 'search', 'tags', 'settings'
+
+ // Folder state
+ folderTree: [],
+ allFolders: [],
+ expandedFolders: new Set(),
+ dragOverFolder: null, // Track which folder is being hovered during drag
+
+ // Tags state
+ allTags: {},
+ selectedTags: [],
+ tagsExpanded: false,
+ tagReloadTimeout: null, // For debouncing tag reloads
+
+ // Search state
+ searchDebounceTimeout: null,
+ isSearching: false,
+
+ // Outline (TOC) state
+ outline: [], // [{level: 1, text: 'Heading', slug: 'heading'}, ...]
+
+ // Backlinks state
+ backlinks: [], // [{path: 'note.md', name: 'Note', references: [{line_number: 5, context: '...', type: 'wikilink'}]}]
+
+ // Scroll sync state
+ isScrolling: false,
+
+ // Unified drag state for notes, folders, and media
+ draggedItem: null, // { path: string, type: 'note' | 'folder' | 'image' | 'audio' | 'video' | 'document' }
+ dropTarget: null, // 'editor' | 'folder' | null
+
+ // Undo/Redo history
+ undoHistory: [],
+ redoHistory: [],
+ maxHistorySize: CONFIG.MAX_UNDO_HISTORY,
+ isUndoRedo: false,
+ hasPendingHistoryChanges: false,
+
+ // Stats plugin state
+ statsPluginEnabled: false,
+ noteStats: null,
+ statsExpanded: false,
+
+ // Note metadata (frontmatter) state
+ noteMetadata: null,
+ metadataExpanded: false,
+ _lastFrontmatter: null, // Cache to avoid re-parsing unchanged frontmatter
+
+ // Sidebar resize state
+ sidebarWidth: CONFIG.DEFAULT_SIDEBAR_WIDTH,
+ isResizing: false,
+
+ // Mobile sidebar state
+ mobileSidebarOpen: false,
+
+ // Split view resize state
+ editorWidth: 50, // percentage
+ isResizingSplit: false,
+
+ // Dropdown state
+ showNewDropdown: false,
+ dropdownTargetFolder: null, // Folder context for "New" dropdown ('' = root, null = not set)
+ dropdownPosition: { top: 0, left: 0 }, // Position for contextual dropdown
+
+ // Template state
+ showTemplateModal: false,
+ availableTemplates: [],
+ selectedTemplate: '',
+ newTemplateNoteName: '',
+
+ // Share state
+ showShareModal: false,
+ shareInfo: null,
+ shareLoading: false,
+ showShareQR: false,
+ shareLinkCopied: false,
+ _sharedNotePaths: new Set(), // O(1) lookup for shared note indicators
+
+ // Quick Switcher state (Ctrl+Alt+P)
+ showQuickSwitcher: false,
+ quickSwitcherQuery: '',
+ quickSwitcherIndex: 0,
+ quickSwitcherResults: [],
+
+ // Homepage state
+ selectedHomepageFolder: '',
+ _homepageCache: {
+ folderPath: null,
+ notes: null,
+ folders: null,
+ breadcrumb: null
+ },
+
+ // Homepage constants
+ HOMEPAGE_MAX_NOTES: 50,
+
+ // Computed-like helpers for homepage (cached for performance)
+ homepageNotes() {
+ // Return cached result if folder hasn't changed
+ if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.notes) {
+ return this._homepageCache.notes;
+ }
+
+ if (!this.folderTree || typeof this.folderTree !== 'object') {
+ return [];
+ }
+
+ const folderNode = this.getFolderNode(this.selectedHomepageFolder || '');
+ const result = (folderNode && Array.isArray(folderNode.notes)) ? folderNode.notes : [];
+
+ // Cache the result
+ this._homepageCache.notes = result;
+ this._homepageCache.folderPath = this.selectedHomepageFolder;
+
+ return result;
+ },
+
+ homepageFolders() {
+ // Return cached result if folder hasn't changed
+ if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.folders) {
+ return this._homepageCache.folders;
+ }
+
+ if (!this.folderTree || typeof this.folderTree !== 'object') {
+ return [];
+ }
+
+ // Get child folders
+ let childFolders = [];
+ if (!this.selectedHomepageFolder) {
+ // Root level: all top-level folders
+ childFolders = Object.entries(this.folderTree)
+ .filter(([key]) => key !== '__root__')
+ .map(([, folder]) => folder);
+ } else {
+ // Inside a folder: get its children
+ const parentFolder = this.getFolderNode(this.selectedHomepageFolder);
+ if (parentFolder && parentFolder.children) {
+ childFolders = Object.values(parentFolder.children);
+ }
+ }
+
+ // Map to simplified structure (note count already cached in folder node)
+ const result = childFolders
+ .map(folder => ({
+ name: folder.name,
+ path: folder.path,
+ noteCount: folder.noteCount || 0 // Use pre-calculated count
+ }))
+ .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
+
+ // Cache the result
+ this._homepageCache.folders = result;
+ this._homepageCache.folderPath = this.selectedHomepageFolder;
+
+ return result;
+ },
+
+ homepageBreadcrumb() {
+ // Return cached result if folder hasn't changed
+ if (this._homepageCache.folderPath === this.selectedHomepageFolder && this._homepageCache.breadcrumb) {
+ return this._homepageCache.breadcrumb;
+ }
+
+ const breadcrumb = [{ name: this.t('homepage.title'), path: '' }];
+
+ if (this.selectedHomepageFolder) {
+ const parts = this.selectedHomepageFolder.split('/').filter(Boolean);
+ let currentPath = '';
+
+ parts.forEach(part => {
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
+ breadcrumb.push({ name: part, path: currentPath });
+ });
+ }
+
+ // Cache the result
+ this._homepageCache.breadcrumb = breadcrumb;
+ this._homepageCache.folderPath = this.selectedHomepageFolder;
+
+ return breadcrumb;
+ },
+
+ // Helper: Format file size nicely
+ formatSize(bytes) {
+ if (!bytes) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
+ },
+
+ // Helper: Format date using current locale
+ formatDate(dateStr) {
+ if (!dateStr) return '';
+ const date = new Date(dateStr);
+ if (isNaN(date.getTime())) return '';
+ return date.toLocaleDateString(this.currentLocale, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric'
+ });
+ },
+
+ getFolderNode(folderPath = '') {
+ if (!this.folderTree || typeof this.folderTree !== 'object') {
+ return null;
+ }
+
+ if (!folderPath) {
+ return this.folderTree['__root__'] || { name: '', path: '', children: {}, notes: [], noteCount: 0 };
+ }
+
+ const parts = folderPath.split('/').filter(Boolean);
+ let currentLevel = this.folderTree;
+ let node = null;
+
+ for (const part of parts) {
+ if (!currentLevel[part]) {
+ return null;
+ }
+ node = currentLevel[part];
+ currentLevel = node.children || {};
+ }
+
+ return node;
+ },
+
+ // Check if app is empty (no notes and no folders)
+ get isAppEmpty() {
+ const notesArray = Array.isArray(this.notes) ? this.notes : [];
+ const foldersArray = Array.isArray(this.allFolders) ? this.allFolders : [];
+ return notesArray.length === 0 && foldersArray.length === 0;
+ },
+
+ // Mermaid state cache
+ lastMermaidTheme: null,
+
+ // Media viewer state
+ currentMedia: '', // Path to current media file (kept as 'currentMedia' for compatibility)
+ currentMediaType: 'image', // 'image', 'audio', 'video', 'document'
+
+ // DOM element cache (to avoid repeated querySelector calls)
+ _domCache: {
+ editor: null,
+ previewContainer: null,
+ previewContent: null
+ },
+ async checkAuthStatus() {
+ if (!this.authEnabled) {
+ this.authenticated = true;
+ return;
+ }
+
+ try {
+ // Test a real protected write route (always requires login)
+ const res = await fetch('/api/notes/__auth_test_dummy_note__.md', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ content: '' })
+ });
+
+ // 401 = not logged in
+ // Any other status = logged in (even if the dummy note creation fails)
+ this.authenticated = res.status !== 401;
+ } catch (e) {
+ this.authenticated = false;
+ }
+ },
+
+ // Initialize app
+ async init() {
+ // Prevent double initialization (Alpine.js may call x-init twice in some cases)
+ if (window.__noteapp_initialized) return;
+ window.__noteapp_initialized = true;
+
+ // Store global reference for native event handlers in x-html content
+ window.$root = this;
+
+ // ESC key to cancel drag operations
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && this.draggedItem) {
+ this.cancelDrag();
+ }
+ });
+
+ await this.loadConfig();
+ await this.loadThemes();
+ await this.initTheme();
+ await this.loadAvailableLocales();
+ // Note: Translations are preloaded synchronously before Alpine init (see index.html)
+ // loadLocale() is only called when user changes language from settings
+ await this.loadNotes();
+ await this.checkAuthStatus();
+ await this.loadSharedNotePaths();
+ await this.loadTemplates();
+ await this.checkStatsPlugin();
+ this.loadLocalSettings();
+
+ // Parse URL and load specific note if provided
+ this.loadItemFromURL();
+
+ // Set initial homepage state ONLY if we're actually on the homepage
+ if (window.location.pathname === '/') {
+ window.history.replaceState({ homepageFolder: '' }, '', '/');
+ document.title = this.appName;
+ }
+
+ // Listen for browser back/forward navigation
+ window.addEventListener('popstate', (e) => {
+ if (e.state && e.state.notePath) {
+ // Navigating to a note
+ const searchQuery = e.state.searchQuery || '';
+ this.loadNote(e.state.notePath, false, searchQuery); // false = don't update history
+
+ // Update search box and trigger search if needed
+ if (searchQuery) {
+ this.searchQuery = searchQuery;
+ this.searchNotes();
+ } else {
+ this.searchQuery = '';
+ this.searchResults = [];
+ this.clearSearchHighlights();
+ }
+ } else if (e.state && e.state.mediaPath) {
+ // Navigating to a media file
+ this.viewMedia(e.state.mediaPath, null, false);
+ } else {
+ // Navigating back to homepage
+ this.currentNote = '';
+ this.noteContent = '';
+ this.currentNoteName = '';
+ this.outline = [];
+ this.backlinks = [];
+ this.shareInfo = null; // Reset share info
+ document.title = this.appName;
+
+ // Restore homepage folder state if it was saved
+ if (e.state && e.state.homepageFolder !== undefined) {
+ this.selectedHomepageFolder = e.state.homepageFolder || '';
+ } else {
+ // No folder state in history, go to root
+ this.selectedHomepageFolder = '';
+ }
+
+ // Invalidate cache to force recalculation
+ this._homepageCache = {
+ folderPath: null,
+ notes: null,
+ folders: null,
+ breadcrumb: null
+ };
+
+ // Clear search
+ this.searchQuery = '';
+ this.searchResults = [];
+ this.clearSearchHighlights();
+ }
+ });
+
+ // Cache DOM references after initial render
+ this.$nextTick(() => {
+ this.refreshDOMCache();
+ });
+
+ // Setup mobile view mode handler
+ this.setupMobileViewMode();
+
+ // Watch view mode changes and auto-save
+ this.$watch('viewMode', (newValue) => {
+ this.saveViewMode();
+ // Scroll to top when switching modes
+ this.$nextTick(() => {
+ this.scrollToTop();
+ });
+ });
+
+ // Watch for changes in note content to re-apply search highlights
+ this.$watch('noteContent', () => {
+ if (this.currentSearchHighlight) {
+ // Re-apply highlights after content changes (with small delay for render)
+ this.$nextTick(() => {
+ setTimeout(() => {
+ // Don't focus editor during content changes (false)
+ this.highlightSearchTerm(this.currentSearchHighlight, false);
+ }, 50);
+ });
+ }
+ });
+
+ // Watch tags panel expanded state and save to localStorage
+ this.$watch('tagsExpanded', () => {
+ this.saveTagsExpanded();
+ });
+
+ // Watch favorites expanded state and save to localStorage
+ this.$watch('favoritesExpanded', () => {
+ this.saveFavoritesExpanded();
+ });
+
+ // Setup keyboard shortcuts (only once to prevent double triggers)
+ if (!window.__noteapp_shortcuts_initialized) {
+ window.__noteapp_shortcuts_initialized = true;
+ window.addEventListener('keydown', (e) => {
+ // Use e.key (not e.code) for letter keys to support non-QWERTY keyboard layouts
+
+ // Ctrl/Cmd + S to save
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
+ e.preventDefault();
+ this.saveNote();
+ }
+
+ // Ctrl/Cmd + Alt + P for Quick Switcher
+ if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'p') {
+ e.preventDefault();
+ this.openQuickSwitcher();
+ return;
+ }
+
+ // Ctrl/Cmd + Alt/Option + N for new note
+ if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'n') {
+ e.preventDefault();
+ this.createNote();
+ }
+
+ // Ctrl/Cmd + Alt/Option + F for new folder
+ if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'f') {
+ e.preventDefault();
+ this.createFolder();
+ }
+
+ // Ctrl/Cmd + Z for undo (without shift or alt)
+ // Use e.key instead of e.code to support non-QWERTY keyboard layouts
+ if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') {
+ e.preventDefault();
+ this.undo();
+ }
+
+ // Ctrl/Cmd + Y OR Ctrl/Cmd+Shift+Z for redo
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') {
+ e.preventDefault();
+ this.redo();
+ }
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') {
+ e.preventDefault();
+ this.redo();
+ }
+
+ // F3 for next search match
+ if (e.code === 'F3' && !e.shiftKey) {
+ e.preventDefault();
+ this.nextMatch();
+ }
+
+ // Shift + F3 for previous search match
+ if (e.code === 'F3' && e.shiftKey) {
+ e.preventDefault();
+ this.previousMatch();
+ }
+
+ // Only apply markdown shortcuts when editor is focused and a note is open
+ const isEditorFocused = document.activeElement?.id === 'note-editor';
+ if (isEditorFocused && this.currentNote) {
+ // Ctrl/Cmd + B for bold
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'b') {
+ e.preventDefault();
+ this.wrapSelection('**', '**', 'bold text');
+ }
+
+ // Ctrl/Cmd + I for italic
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'i') {
+ e.preventDefault();
+ this.wrapSelection('*', '*', 'italic text');
+ }
+
+ // Ctrl/Cmd + K for link
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
+ e.preventDefault();
+ this.insertLink();
+ }
+
+ // Ctrl/Cmd + Alt/Option + T for table
+ if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 't') {
+ e.preventDefault();
+ this.insertTable();
+ }
+
+ // Ctrl/Cmd + Alt/Option + Z for Zen mode
+ if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'z') {
+ e.preventDefault();
+ this.toggleZenMode();
+ }
+ }
+
+ // Escape to exit Zen mode (works anywhere)
+ if (e.key === 'Escape' && this.zenMode) {
+ e.preventDefault();
+ this.toggleZenMode();
+ }
+ });
+ }
+
+ // Note: setupScrollSync() is called when a note is loaded (see loadNote())
+
+ // Listen for system theme changes
+ if (window.matchMedia) {
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
+ if (this.currentTheme === 'system') {
+ this.applyTheme('system');
+ }
+ });
+ }
+
+ // Listen for fullscreen changes (to sync zen mode state)
+ document.addEventListener('fullscreenchange', () => {
+ if (!document.fullscreenElement && this.zenMode) {
+ // User exited fullscreen manually, exit zen mode too
+ this.zenMode = false;
+ this.viewMode = this.previousViewMode;
+ }
+ });
+ },
+
+ // Load app configuration
+ async loadConfig() {
+ try {
+ const response = await fetch('/api/config');
+ const config = await response.json();
+ this.appName = config.name;
+ this.appVersion = config.version || '0.0.0';
+ this.authEnabled = config.authentication?.enabled || false;
+ this.demoMode = config.demoMode || false;
+ this.alreadyDonated = config.alreadyDonated || false;
+ } catch (error) {
+ console.error('Failed to load config:', error);
+ }
+ },
+
+ // Load available themes from backend
+ async loadThemes() {
+ try {
+ const response = await fetch('/api/themes');
+ const data = await response.json();
+
+ // Use theme names directly from backend (already include emojis)
+ this.availableThemes = data.themes;
+ } catch (error) {
+ console.error('Failed to load themes:', error);
+ // Fallback to default themes
+ this.availableThemes = [
+ { id: 'light', name: '๐ Light' },
+ { id: 'dark', name: '๐ Dark' }
+ ];
+ }
+ },
+
+ // Initialize theme system
+ async initTheme() {
+ // Load saved theme preference from localStorage
+ const savedTheme = localStorage.getItem('noteDiscoveryTheme') || 'light';
+ this.currentTheme = savedTheme;
+ await this.applyTheme(savedTheme);
+ },
+
+ // Set and apply theme
+ async setTheme(themeId) {
+ this.currentTheme = themeId;
+ localStorage.setItem('noteDiscoveryTheme', themeId);
+ await this.applyTheme(themeId);
+ },
+
+ // Syntax highlighting toggle
+ toggleSyntaxHighlight() {
+ this.syntaxHighlightEnabled = !this.syntaxHighlightEnabled;
+ localStorage.setItem('syntaxHighlightEnabled', this.syntaxHighlightEnabled);
+ if (this.syntaxHighlightEnabled) {
+ this.updateSyntaxHighlight();
+ }
+ },
+
+ // Load all localStorage settings at once using centralized config
+ loadLocalSettings() {
+ for (const [prop, config] of Object.entries(LOCAL_SETTINGS)) {
+ try {
+ const saved = localStorage.getItem(config.key);
+
+ if (saved === null) {
+ // Use default value if not set
+ this[prop] = config.default;
+ } else if (config.type === 'boolean') {
+ this[prop] = saved === 'true';
+ } else if (config.type === 'number') {
+ const num = parseFloat(saved);
+ // Validate range if specified
+ if (!isNaN(num) &&
+ (config.min === undefined || num >= config.min) &&
+ (config.max === undefined || num <= config.max)) {
+ this[prop] = num;
+ } else {
+ this[prop] = config.default;
+ }
+ } else if (config.type === 'string') {
+ // Validate against allowed values if specified
+ if (!config.valid || config.valid.includes(saved)) {
+ this[prop] = saved;
+ } else {
+ this[prop] = config.default;
+ }
+ } else if (config.type === 'json') {
+ this[prop] = JSON.parse(saved);
+ }
+ } catch (error) {
+ console.error(`Error loading setting ${prop}:`, error);
+ this[prop] = config.default;
+ }
+ }
+
+ // Special case: favorites also needs to update the Set for O(1) lookups
+ this.favoritesSet = new Set(this.favorites);
+ },
+
+ // Readable line length toggle (for preview max-width)
+ toggleReadableLineLength() {
+ this.readableLineLength = !this.readableLineLength;
+ localStorage.setItem('readableLineLength', this.readableLineLength);
+ },
+
+ // Hide underscore folders toggle (hides _attachments, _templates, etc. from sidebar)
+ toggleHideUnderscoreFolders() {
+ this.hideUnderscoreFolders = !this.hideUnderscoreFolders;
+ localStorage.setItem('hideUnderscoreFolders', this.hideUnderscoreFolders);
+ },
+
+ // Tab inserts tab toggle (Tab key inserts tab character instead of changing focus)
+ toggleTabInsertsTab() {
+ this.tabInsertsTab = !this.tabInsertsTab;
+ localStorage.setItem('tabInsertsTab', this.tabInsertsTab);
+ },
+
+ // Handle Tab key in editor (inserts tab if setting enabled)
+ handleTabKey(event) {
+ if (!this.tabInsertsTab) return;
+
+ event.preventDefault();
+ const textarea = event.target;
+ const start = textarea.selectionStart;
+ const end = textarea.selectionEnd;
+ this.noteContent = this.noteContent.substring(0, start) + '\t' + this.noteContent.substring(end);
+ this.$nextTick(() => {
+ textarea.selectionStart = textarea.selectionEnd = start + 1;
+ });
+ this.autoSave();
+ },
+
+ // Update syntax highlight overlay (debounced, called on input)
+ updateSyntaxHighlight() {
+ if (!this.syntaxHighlightEnabled) return;
+
+ clearTimeout(this.syntaxHighlightTimeout);
+ this.syntaxHighlightTimeout = setTimeout(() => {
+ const overlay = document.getElementById('syntax-overlay');
+ if (overlay) {
+ overlay.innerHTML = this.highlightMarkdown(this.noteContent);
+ }
+ }, 50); // 50ms debounce
+ },
+
+ // Sync overlay scroll with textarea
+ syncOverlayScroll() {
+ const textarea = document.getElementById('note-editor');
+ const overlay = document.getElementById('syntax-overlay');
+ if (textarea && overlay) {
+ overlay.scrollTop = textarea.scrollTop;
+ overlay.scrollLeft = textarea.scrollLeft;
+ }
+ },
+
+ // Highlight markdown syntax
+ highlightMarkdown(text) {
+ if (!text) return '';
+
+ // Escape HTML first
+ let html = this.escapeHtml(text);
+
+ // Store code blocks and inline code with placeholders to protect from other patterns
+ const codePlaceholders = [];
+
+ // Code blocks FIRST - protect them before anything else
+ html = html.replace(/(```[\s\S]*?```)/g, (match) => {
+ codePlaceholders.push('' + match + '');
+ return `\x00CODE${codePlaceholders.length - 1}\x00`;
+ });
+
+ // Frontmatter (must be at VERY start of document, not any line)
+ if (html.startsWith('---\n')) {
+ html = html.replace(/^(---\n[\s\S]*?\n---)/, (match) => {
+ codePlaceholders.push('' + match + '');
+ return `\x00CODE${codePlaceholders.length - 1}\x00`;
+ });
+ }
+
+ // Inline code - protect it
+ html = html.replace(/`([^`\n]+)`/g, (match) => {
+ codePlaceholders.push('' + match + '');
+ return `\x00CODE${codePlaceholders.length - 1}\x00`;
+ });
+
+ // Now apply other patterns (they won't match inside protected code)
+
+ // Headings - capture the whitespace to preserve exact characters (tabs vs spaces)
+ // This prevents cursor/selection misalignment
+ html = html.replace(/^(#{1,6})(\s)(.*)$/gm, '$1$2$3');
+
+ // Bold (must come before italic)
+ html = html.replace(/\*\*([^*]+)\*\*/g, '**$1**');
+ html = html.replace(/__([^_]+)__/g, '__$1__');
+
+ // Italic
+ html = html.replace(/(?*$1*');
+ html = html.replace(/(?_$1_');
+
+ // Wikilinks [[...]]
+ html = html.replace(/\[\[([^\]]+)\]\]/g, '[[$1]]');
+
+ // Links [text](url)
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[$1]($2)');
+
+ // Lists - use ([ \t]) to capture the space/tab and preserve exact characters
+ // IMPORTANT: Don't add any characters (like \u200B) that aren't in the original,
+ // as this breaks cursor/selection alignment between textarea and overlay
+ html = html.replace(/^(\s*)([-*+])([ \t])(.*)$/gm, (match, indent, bullet, space, rest) => {
+ return `${indent}${bullet}${space}${rest}`;
+ });
+ html = html.replace(/^(\s*)(\d+\.)([ \t])(.*)$/gm, (match, indent, bullet, space, rest) => {
+ return `${indent}${bullet}${space}${rest}`;
+ });
+
+ // Blockquotes
+ html = html.replace(/^(>.*)$/gm, '$1');
+
+ // Horizontal rules
+ html = html.replace(/^([-*_]{3,})$/gm, '$1');
+
+ // Restore protected code blocks
+ html = html.replace(/\x00CODE(\d+)\x00/g, (match, index) => codePlaceholders[parseInt(index)]);
+
+ // Add trailing space to match textarea's phantom line for cursor
+ // This ensures the overlay and textarea have the same content height
+ html += '\n ';
+
+ return html;
+ },
+
+ // Apply theme to document
+ async applyTheme(themeId) {
+ // Load theme CSS from file
+ try {
+ const response = await fetch(`/api/themes/${themeId}`);
+ const data = await response.json();
+
+ // Create or update style element
+ let styleEl = document.getElementById('dynamic-theme');
+ if (!styleEl) {
+ styleEl = document.createElement('style');
+ styleEl.id = 'dynamic-theme';
+ document.head.appendChild(styleEl);
+ }
+ styleEl.textContent = data.css;
+
+ // Set data attribute for theme-specific selectors
+ document.documentElement.setAttribute('data-theme', themeId);
+
+ // Load appropriate Highlight.js theme for code syntax highlighting
+ const highlightTheme = document.getElementById('highlight-theme');
+ if (highlightTheme) {
+ if (themeId === 'light') {
+ highlightTheme.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css';
+ } else {
+ // Use dark theme for dark/custom themes
+ highlightTheme.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css';
+ }
+ }
+
+ // Re-render Mermaid diagrams with new theme if there's a current note
+ if (this.currentNote) {
+ // Small delay to allow theme CSS to load
+ setTimeout(() => {
+ // Clear existing Mermaid renders
+ const previewContent = document.querySelector('.markdown-preview');
+ if (previewContent) {
+ const mermaidContainers = previewContent.querySelectorAll('.mermaid-rendered');
+ mermaidContainers.forEach(container => {
+ // Replace with the original code block for re-rendering
+ const parent = container.parentElement;
+ if (parent && container.dataset.originalCode) {
+ const pre = document.createElement('pre');
+ const code = document.createElement('code');
+ code.className = 'language-mermaid';
+ code.textContent = container.dataset.originalCode;
+ pre.appendChild(code);
+ parent.replaceChild(pre, container);
+ }
+ });
+ }
+ // Re-render with new theme
+ this.renderMermaid();
+ }, 100);
+ }
+
+ // Refresh graph if visible (longer delay to ensure CSS is applied)
+ if (this.showGraph) {
+ setTimeout(() => this.initGraph(), 300);
+ }
+
+ // Update PWA theme-color meta tag to match current theme
+ const themeColorMeta = document.querySelector('meta[name="theme-color"]');
+ if (themeColorMeta) {
+ // Get the accent color from CSS variables
+ const accentColor = getComputedStyle(document.documentElement)
+ .getPropertyValue('--accent-primary').trim() || '#667eea';
+ themeColorMeta.setAttribute('content', accentColor);
+ }
+ } catch (error) {
+ console.error('Failed to load theme:', error);
+ }
+ },
+
+ // ==================== INTERNATIONALIZATION ====================
+
+ // Translation function - get translated string by key
+ t(key, params = {}) {
+ const keys = key.split('.');
+ let value = this.translations;
+
+ for (const k of keys) {
+ value = value?.[k];
+ }
+
+ // Fallback to key if translation not found (silently - default translations are inline)
+ if (typeof value !== 'string') {
+ return key;
+ }
+
+ // Replace {{param}} placeholders
+ return value.replace(/\{\{(\w+)\}\}/g, (_, name) => params[name] ?? `{{${name}}}`);
+ },
+
+ /**
+ * Get localized error message from FilenameValidator result
+ * @param {object} validation - The validation result from FilenameValidator
+ * @param {string} type - 'note' or 'folder'
+ * @returns {string} Localized error message
+ */
+ getValidationErrorMessage(validation, type = 'note') {
+ switch (validation.error) {
+ case 'empty':
+ return type === 'note'
+ ? this.t('notes.empty_name')
+ : this.t('folders.invalid_name');
+ case 'forbidden_chars':
+ return this.t('validation.forbidden_chars', {
+ chars: validation.forbiddenChars
+ });
+ case 'reserved_name':
+ return this.t('validation.reserved_name');
+ case 'invalid_dot':
+ return this.t('validation.invalid_dot');
+ case 'trailing_dot_space':
+ return this.t('validation.trailing_dot_space');
+ default:
+ return type === 'note'
+ ? this.t('notes.invalid_name')
+ : this.t('folders.invalid_name');
+ }
+ },
+
+ // Load available locales from backend
+ async loadAvailableLocales() {
+ try {
+ const response = await fetch('/api/locales');
+ const data = await response.json();
+ this.availableLocales = data.locales || [];
+ } catch (error) {
+ console.error('Failed to load available locales:', error);
+ this.availableLocales = [{ code: 'en-US', name: 'English', flag: '๐บ๐ธ' }];
+ }
+ },
+
+ // Load translations for a specific locale
+ async loadLocale(localeCode = null) {
+ const targetLocale = localeCode || localStorage.getItem('locale') || 'en-US';
+
+ try {
+ const response = await fetch(`/api/locales/${targetLocale}`);
+ if (response.ok) {
+ this.translations = await response.json();
+ this.currentLocale = targetLocale;
+ localStorage.setItem('locale', targetLocale);
+ } else if (targetLocale !== 'en-US') {
+ // Fallback to en-US if requested locale not found
+ await this.loadLocale('en-US');
+ }
+ } catch (error) {
+ console.error('Failed to load locale:', error);
+ // If en-US also fails, translations will be empty and t() will return keys
+ if (targetLocale !== 'en-US') {
+ await this.loadLocale('en-US');
+ }
+ }
+ },
+
+ // Change locale and reload translations
+ async changeLocale(localeCode) {
+ await this.loadLocale(localeCode);
+ },
+
+ // ==================== END INTERNATIONALIZATION ====================
+
+ // Load all notes
+ async loadNotes() {
+ try {
+ const response = await fetch('/api/notes');
+ const data = await response.json();
+ this.notes = data.notes;
+ this.allFolders = data.folders || [];
+ this.buildNoteLookupMaps(); // Build O(1) lookup maps
+ this.buildFolderTree();
+ await this.loadTags(); // Load tags after notes are loaded
+ } catch (error) {
+ ErrorHandler.handle('load notes', error);
+ }
+ },
+
+ // Build lookup maps for O(1) wikilink resolution
+ buildNoteLookupMaps() {
+ // Clear existing maps
+ this._noteLookup.byPath.clear();
+ this._noteLookup.byPathLower.clear();
+ this._noteLookup.byName.clear();
+ this._noteLookup.byNameLower.clear();
+ this._noteLookup.byEndPath.clear();
+ this._mediaLookup.clear();
+
+ for (const note of this.notes) {
+ const path = note.path;
+ const pathLower = path.toLowerCase();
+ const name = note.name;
+ const nameLower = name.toLowerCase();
+
+ // Handle media files separately - build media lookup map
+ if (note.type !== 'note') {
+ // Map filename WITH extension (case-insensitive) to full path
+ // Use path to get filename with extension (note.name is stem without extension)
+ const filenameWithExt = path.split('/').pop().toLowerCase();
+ // First match wins if there are duplicates
+ if (!this._mediaLookup.has(filenameWithExt)) {
+ this._mediaLookup.set(filenameWithExt, path);
+ }
+ continue;
+ }
+
+ // Notes only from here
+ const nameWithoutMd = name.replace(/\.md$/i, '');
+ const nameWithoutMdLower = nameWithoutMd.toLowerCase();
+
+ // Store all variations for fast lookup
+ this._noteLookup.byPath.set(path, true);
+ this._noteLookup.byPath.set(path.replace(/\.md$/i, ''), true);
+ this._noteLookup.byPathLower.set(pathLower, true);
+ this._noteLookup.byPathLower.set(pathLower.replace(/\.md$/i, ''), true);
+ this._noteLookup.byName.set(name, true);
+ this._noteLookup.byName.set(nameWithoutMd, true);
+ this._noteLookup.byNameLower.set(nameLower, true);
+ this._noteLookup.byNameLower.set(nameWithoutMdLower, true);
+
+ // End path matching (for /folder/note style links)
+ this._noteLookup.byEndPath.set('/' + nameWithoutMdLower, true);
+ this._noteLookup.byEndPath.set('/' + nameLower, true);
+ }
+ },
+
+ // Fast O(1) check if a wikilink target exists
+ wikiLinkExists(linkTarget) {
+ const targetLower = linkTarget.toLowerCase();
+
+ // Check all lookup maps
+ return (
+ this._noteLookup.byPath.has(linkTarget) ||
+ this._noteLookup.byPath.has(linkTarget + '.md') ||
+ this._noteLookup.byPathLower.has(targetLower) ||
+ this._noteLookup.byPathLower.has(targetLower + '.md') ||
+ this._noteLookup.byName.has(linkTarget) ||
+ this._noteLookup.byNameLower.has(targetLower) ||
+ this._noteLookup.byEndPath.has('/' + targetLower) ||
+ this._noteLookup.byEndPath.has('/' + targetLower + '.md')
+ );
+ },
+
+ // Resolve media wikilink to full path (O(1) lookup)
+ // Returns the full path if found, null otherwise
+ resolveMediaWikilink(mediaName) {
+ const nameLower = mediaName.toLowerCase();
+ return this._mediaLookup.get(nameLower) || null;
+ },
+
+ // Load all tags
+ async loadTags() {
+ try {
+ const response = await fetch('/api/tags');
+ const data = await response.json();
+ this.allTags = data.tags || {};
+ } catch (error) {
+ ErrorHandler.handle('load tags', error, false); // Don't show alert, tags are optional
+ }
+ },
+
+ // Debounced tag reload (prevents excessive API calls during typing)
+ loadTagsDebounced() {
+ // Clear existing timeout
+ if (this.tagReloadTimeout) {
+ clearTimeout(this.tagReloadTimeout);
+ }
+
+ // Set new timeout - reload tags 2 seconds after last save
+ this.tagReloadTimeout = setTimeout(() => {
+ this.loadTags();
+ }, 2000);
+ },
+
+ // Toggle tag selection for filtering
+ toggleTag(tag) {
+ const index = this.selectedTags.indexOf(tag);
+ if (index > -1) {
+ this.selectedTags.splice(index, 1);
+ } else {
+ this.selectedTags.push(tag);
+ }
+
+ // Apply unified filtering
+ this.applyFilters();
+ },
+
+ // ========================================================================
+ // Template Methods
+ // ========================================================================
+
+ // Load available templates from _templates folder
+ async loadTemplates() {
+ try {
+ const response = await fetch('/api/templates');
+ const data = await response.json();
+ this.availableTemplates = data.templates || [];
+ } catch (error) {
+ ErrorHandler.handle('load templates', error, false); // Don't show alert, templates are optional
+ }
+ },
+
+ // Create a new note from a template
+ async createNoteFromTemplate() {
+ if (!this.selectedTemplate || !this.newTemplateNoteName.trim()) {
+ return;
+ }
+
+ try {
+ // Validate the note name
+ const validation = FilenameValidator.validateFilename(this.newTemplateNoteName);
+ if (!validation.valid) {
+ alert(this.getValidationErrorMessage(validation, 'note'));
+ return;
+ }
+
+ // Determine the note path based on dropdown context
+ let notePath = validation.sanitized;
+ if (!notePath.endsWith('.md')) {
+ notePath += '.md';
+ }
+
+ // Determine target folder: use dropdown context if set, otherwise homepage folder
+ let targetFolder;
+ if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) {
+ targetFolder = this.dropdownTargetFolder; // Can be '' for root or a folder path
+ } else {
+ targetFolder = this.selectedHomepageFolder || '';
+ }
+
+ // If we have a target folder, create note in that folder
+ if (targetFolder) {
+ notePath = `${targetFolder}/${notePath}`;
+ }
+
+ // CRITICAL: Check if note already exists
+ const existingNote = this.notes.find(note => note.path === notePath);
+ if (existingNote) {
+ alert(this.t('notes.already_exists', { name: validation.sanitized }));
+ return;
+ }
+
+ // Create note from template
+ const response = await fetch('/api/templates/create-note', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ templateName: this.selectedTemplate,
+ notePath: notePath
+ })
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ alert(error.detail || this.t('templates.create_failed'));
+ return;
+ }
+
+ const data = await response.json();
+
+ // Close modal and reset state
+ this.showTemplateModal = false;
+ this.selectedTemplate = '';
+ this.newTemplateNoteName = '';
+
+ // Reload notes and open the new note
+ await this.loadNotes();
+ await this.loadNote(data.path);
+ this.focusEditorForNewNote();
+
+ } catch (error) {
+ ErrorHandler.handle('create note from template', error);
+ }
+ },
+
+ // Clear all tag filters
+ clearTagFilters() {
+ this.selectedTags = [];
+
+ // Apply unified filtering
+ this.applyFilters();
+ },
+
+ // ========================================================================
+ // Outline (TOC) Methods
+ // ========================================================================
+
+ // Extract headings from markdown content for the outline
+ extractOutline(content) {
+ if (!content) {
+ this.outline = [];
+ this.backlinks = [];
+ return;
+ }
+
+ const headings = [];
+ const lines = content.split('\n');
+ const slugCounts = {}; // Track duplicate slugs
+
+ // Skip frontmatter and code blocks
+ let inFrontmatter = false;
+ let inCodeBlock = false;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+
+ // Handle frontmatter
+ if (i === 0 && line.trim() === '---') {
+ inFrontmatter = true;
+ continue;
+ }
+ if (inFrontmatter) {
+ if (line.trim() === '---') {
+ inFrontmatter = false;
+ }
+ continue;
+ }
+
+ // Handle fenced code blocks (``` or ~~~)
+ if (line.trim().startsWith('```') || line.trim().startsWith('~~~')) {
+ inCodeBlock = !inCodeBlock;
+ continue;
+ }
+ if (inCodeBlock) {
+ continue;
+ }
+
+ // Match heading lines (# to ######)
+ const match = line.match(/^(#{1,6})\s+(.+)$/);
+ if (match) {
+ const level = match[1].length;
+ const text = match[2].trim();
+
+ // Generate slug (GitHub-style)
+ let slug = text
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, '') // Remove special chars
+ .replace(/\s+/g, '-') // Spaces to dashes
+ .replace(/-+/g, '-'); // Multiple dashes to single
+
+ // Handle duplicate slugs
+ if (slugCounts[slug] !== undefined) {
+ slugCounts[slug]++;
+ slug = `${slug}-${slugCounts[slug]}`;
+ } else {
+ slugCounts[slug] = 0;
+ }
+
+ headings.push({
+ level,
+ text,
+ slug,
+ line: i + 1 // 1-indexed line number
+ });
+ }
+ }
+
+ this.outline = headings;
+ },
+
+ // Scroll to a heading in the editor or preview
+ scrollToHeading(heading) {
+ if (this.viewMode === 'preview' || this.viewMode === 'split') {
+ // In preview/split mode, scroll the preview pane
+ const preview = document.querySelector('.markdown-preview');
+ if (preview) {
+ // Find the heading element by text content (more reliable than ID)
+ const headingElements = preview.querySelectorAll('h1, h2, h3, h4, h5, h6');
+ for (const el of headingElements) {
+ if (el.textContent.trim() === heading.text) {
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ // Add a brief highlight effect
+ el.style.transition = 'background-color 0.3s';
+ el.style.backgroundColor = 'var(--accent-light)';
+ setTimeout(() => {
+ el.style.backgroundColor = '';
+ }, 1000);
+ return;
+ }
+ }
+ }
+ }
+
+ if (this.viewMode === 'edit' || this.viewMode === 'split') {
+ // In edit/split mode, scroll the editor to the line
+ const textarea = document.querySelector('.editor-textarea');
+ if (textarea && heading.line) {
+ const lines = textarea.value.split('\n');
+ let charPos = 0;
+
+ // Calculate character position of the heading line
+ for (let i = 0; i < heading.line - 1 && i < lines.length; i++) {
+ charPos += lines[i].length + 1; // +1 for newline
+ }
+
+ // Set cursor position and scroll
+ textarea.focus();
+ textarea.setSelectionRange(charPos, charPos);
+
+ // Calculate scroll position (approximate)
+ const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight) || 24;
+ const scrollTop = (heading.line - 1) * lineHeight - textarea.clientHeight / 3;
+ textarea.scrollTop = Math.max(0, scrollTop);
+ }
+ }
+ },
+
+ // Navigate to a backlink (note that links to current note)
+ navigateToBacklink(backlinkPath) {
+ this.loadNote(backlinkPath);
+ },
+
+ // Unified filtering logic combining tags and text search
+ async applyFilters() {
+ const hasTextSearch = this.searchQuery.trim().length > 0;
+ const hasTagFilter = this.selectedTags.length > 0;
+
+ // Case 1: No filters at all โ show full folder tree
+ if (!hasTextSearch && !hasTagFilter) {
+ this.isSearching = false;
+ this.searchResults = [];
+ this.currentSearchHighlight = '';
+ this.clearSearchHighlights();
+ this.buildFolderTree();
+ return;
+ }
+
+ // Case 2: Only tag filter โ convert to flat list of matching notes
+ if (hasTagFilter && !hasTextSearch) {
+ this.isSearching = false;
+ this.searchResults = this.notes.filter(note =>
+ note.type === 'note' && this.noteMatchesTags(note)
+ );
+ this.currentSearchHighlight = '';
+ this.clearSearchHighlights();
+ return;
+ }
+
+ // Case 3: Text search (with or without tag filter)
+ if (hasTextSearch) {
+ this.isSearching = true;
+ try {
+ const response = await fetch(`/api/search?q=${encodeURIComponent(this.searchQuery)}`);
+ const data = await response.json();
+
+ // Apply tag filtering to search results if tags are selected
+ let results = data.results;
+ if (hasTagFilter) {
+ results = results.filter(result => {
+ const note = this.notes.find(n => n.path === result.path);
+ return note ? this.noteMatchesTags(note) : false;
+ });
+ }
+
+ this.searchResults = results;
+
+ // Highlight search term in current note if open
+ if (this.currentNote && this.noteContent) {
+ this.currentSearchHighlight = this.searchQuery;
+ this.$nextTick(() => {
+ this.highlightSearchTerm(this.searchQuery, false);
+ });
+ }
+ } catch (error) {
+ console.error('Search failed:', error);
+ this.searchResults = [];
+ } finally {
+ this.isSearching = false;
+ }
+ }
+ },
+
+ // Check if a note matches selected tags (AND logic)
+ noteMatchesTags(note) {
+ if (this.selectedTags.length === 0) {
+ return true; // No filter active
+ }
+ if (!note.tags || note.tags.length === 0) {
+ return false; // Note has no tags but filter is active
+ }
+ // Check if note has ALL selected tags (AND logic)
+ return this.selectedTags.every(tag => note.tags.includes(tag));
+ },
+
+ // Get all tags sorted by name
+ get sortedTags() {
+ return Object.entries(this.allTags).sort((a, b) => a[0].localeCompare(b[0]));
+ },
+
+ // Get tags for current note
+ get currentNoteTags() {
+ if (!this.currentNote) return [];
+ const note = this.notes.find(n => n.path === this.currentNote);
+ return note && note.tags ? note.tags : [];
+ },
+
+ // ==================== FAVORITES ====================
+
+ // Save favorites to localStorage
+ saveFavorites() {
+ try {
+ localStorage.setItem('noteFavorites', JSON.stringify(this.favorites));
+ } catch (e) {
+ console.warn('Could not save favorites to localStorage');
+ }
+ },
+
+ // Check if a note is favorited (O(1) lookup)
+ isFavorite(notePath) {
+ return this.favoritesSet.has(notePath);
+ },
+
+ // Toggle favorite status for a note
+ toggleFavorite(notePath = null) {
+ const path = notePath || this.currentNote;
+ if (!path) return;
+
+ if (this.favoritesSet.has(path)) {
+ // Remove from favorites
+ this.favorites = this.favorites.filter(f => f !== path);
+ } else {
+ // Add to favorites
+ this.favorites = [...this.favorites, path];
+ }
+ // Recreate Set from array for consistency
+ this.favoritesSet = new Set(this.favorites);
+ this.saveFavorites();
+ },
+
+ // Get favorite notes with full details (for display)
+ get favoriteNotes() {
+ return this.favorites
+ .map(path => {
+ // Find note by exact path or case-insensitive match
+ let note = this.notes.find(n => n.path === path);
+ if (!note) {
+ note = this.notes.find(n => n.path.toLowerCase() === path.toLowerCase());
+ }
+ if (!note) return null;
+ return {
+ path: note.path, // Use actual path from notes (fixes case issues)
+ name: note.path.split('/').pop().replace('.md', ''),
+ folder: note.folder || ''
+ };
+ })
+ .filter(Boolean); // Remove nulls (deleted notes)
+ },
+
+ saveFavoritesExpanded() {
+ try {
+ localStorage.setItem('favoritesExpanded', this.favoritesExpanded.toString());
+ } catch (e) {
+ console.error('Error saving favorites expanded state:', e);
+ }
+ },
+
+ // Get current note's last modified time as relative string
+ get lastEditedText() {
+ if (!this.currentNote) return '';
+ const note = this.notes.find(n => n.path === this.currentNote);
+ if (!note || !note.modified) return '';
+
+ const modified = new Date(note.modified);
+ const now = new Date();
+ const diffMs = now - modified;
+ const diffSecs = Math.floor(diffMs / 1000);
+ const diffMins = Math.floor(diffSecs / 60);
+ const diffHours = Math.floor(diffMins / 60);
+ const diffDays = Math.floor(diffHours / 24);
+
+ if (diffSecs < 60) return this.t('editor.just_now');
+ if (diffMins < 60) return this.t('editor.minutes_ago', { count: diffMins });
+ if (diffHours < 24) return this.t('editor.hours_ago', { count: diffHours });
+ if (diffDays < 7) return this.t('editor.days_ago', { count: diffDays });
+
+ // For older dates, show the date in selected locale
+ return modified.toLocaleDateString(this.currentLocale, { month: 'short', day: 'numeric' });
+ },
+
+ // Parse tags from markdown content (matches backend logic)
+ parseTagsFromContent(content) {
+ if (!content || !content.trim().startsWith('---')) {
+ return [];
+ }
+
+ try {
+ const lines = content.split('\n');
+ if (lines[0].trim() !== '---') return [];
+
+ // Find closing ---
+ let endIdx = -1;
+ for (let i = 1; i < lines.length; i++) {
+ if (lines[i].trim() === '---') {
+ endIdx = i;
+ break;
+ }
+ }
+
+ if (endIdx === -1) return [];
+
+ const frontmatterLines = lines.slice(1, endIdx);
+ const tags = [];
+ let inTagsList = false;
+
+ for (const line of frontmatterLines) {
+ const stripped = line.trim();
+
+ // Check for inline array: tags: [tag1, tag2]
+ if (stripped.startsWith('tags:')) {
+ const rest = stripped.substring(5).trim();
+ if (rest.startsWith('[') && rest.endsWith(']')) {
+ const tagsStr = rest.substring(1, rest.length - 1);
+ const rawTags = tagsStr.split(',').map(t => t.trim());
+ tags.push(...rawTags.filter(t => t).map(t => t.toLowerCase()));
+ break;
+ } else if (rest) {
+ tags.push(rest.toLowerCase());
+ break;
+ } else {
+ inTagsList = true;
+ }
+ } else if (inTagsList) {
+ if (stripped.startsWith('-')) {
+ const tag = stripped.substring(1).trim();
+ if (tag && !tag.startsWith('#')) {
+ tags.push(tag.toLowerCase());
+ }
+ } else if (stripped && !stripped.startsWith('#')) {
+ break;
+ }
+ }
+ }
+
+ return [...new Set(tags)].sort();
+ } catch (e) {
+ console.error('Error parsing tags:', e);
+ return [];
+ }
+ },
+
+ // Build folder tree structure
+ buildFolderTree() {
+ const tree = {};
+
+ // Add ALL folders from backend (including empty ones)
+ this.allFolders.forEach(folderPath => {
+ const parts = folderPath.split('/');
+ let current = tree;
+
+ parts.forEach((part, index) => {
+ const fullPath = parts.slice(0, index + 1).join('/');
+
+ if (!current[part]) {
+ current[part] = {
+ name: part,
+ path: fullPath,
+ children: {},
+ notes: []
+ };
+ }
+ current = current[part].children;
+ });
+ });
+
+ // Add ALL notes to their folders (no filtering - tree only shown when no filters active)
+ this.notes.forEach(note => {
+ if (!note.folder) {
+ // Root level note
+ if (!tree['__root__']) {
+ tree['__root__'] = {
+ name: '',
+ path: '',
+ children: {},
+ notes: []
+ };
+ }
+ tree['__root__'].notes.push(note);
+ } else {
+ // Navigate to the folder and add note
+ const parts = note.folder.split('/');
+ let current = tree;
+
+ for (let i = 0; i < parts.length; i++) {
+ if (!current[parts[i]]) {
+ current[parts[i]] = {
+ name: parts[i],
+ path: parts.slice(0, i + 1).join('/'),
+ children: {},
+ notes: []
+ };
+ }
+ if (i === parts.length - 1) {
+ current[parts[i]].notes.push(note);
+ } else {
+ current = current[parts[i]].children;
+ }
+ }
+ }
+ });
+
+ // Sort all notes arrays alphabetically (create new sorted arrays for reactivity)
+ const sortNotes = (obj) => {
+ if (obj.notes && obj.notes.length > 0) {
+ // Create a new sorted array instead of mutating for Alpine reactivity
+ obj.notes = [...obj.notes].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
+ }
+ if (obj.children && Object.keys(obj.children).length > 0) {
+ Object.values(obj.children).forEach(child => sortNotes(child));
+ }
+ };
+
+ // Sort notes in root (create new array for reactivity)
+ if (tree['__root__'] && tree['__root__'].notes) {
+ tree['__root__'].notes = [...tree['__root__'].notes].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
+ }
+
+ // Sort notes in all folders
+ Object.values(tree).forEach(folder => {
+ if (folder.path !== undefined) { // Skip __root__ as it was already sorted
+ sortNotes(folder);
+ }
+ });
+
+ // Calculate and cache note counts recursively (for performance)
+ const calculateNoteCounts = (folderNode) => {
+ const directNotes = folderNode.notes ? folderNode.notes.length : 0;
+
+ if (!folderNode.children || Object.keys(folderNode.children).length === 0) {
+ folderNode.noteCount = directNotes;
+ return directNotes;
+ }
+
+ const childNotesCount = Object.values(folderNode.children).reduce(
+ (total, child) => total + calculateNoteCounts(child),
+ 0
+ );
+
+ folderNode.noteCount = directNotes + childNotesCount;
+ return folderNode.noteCount;
+ };
+
+ // Calculate note counts for all folders
+ Object.values(tree).forEach(folder => {
+ if (folder.path !== undefined || folder === tree['__root__']) {
+ calculateNoteCounts(folder);
+ }
+ });
+
+ // Invalidate homepage cache when tree is rebuilt
+ this._homepageCache = {
+ folderPath: null,
+ notes: null,
+ folders: null,
+ breadcrumb: null
+ };
+
+ // Assign new tree (Alpine will detect the change)
+ this.folderTree = tree;
+ },
+
+ // =====================================================================
+ // DATA-ATTRIBUTE BASED HANDLERS
+ // These read path/name/type from data-* attributes, avoiding JS escaping issues
+ // =====================================================================
+
+ // Escape strings for HTML attributes (simpler than JS escaping)
+ escapeHtmlAttr(str) {
+ if (!str) return '';
+ return str
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ .replace(//g, '>');
+ },
+
+ // Folder handlers - read from dataset
+ handleFolderClick(el) {
+ this.toggleFolder(el.dataset.path);
+ },
+ handleFolderDragOver(el, event) {
+ event.preventDefault();
+ this.dragOverFolder = el.dataset.path;
+ el.classList.add('drag-over');
+ },
+ handleFolderDragLeave(el) {
+ this.dragOverFolder = null;
+ el.classList.remove('drag-over');
+ },
+ handleFolderDrop(el, event) {
+ event.stopPropagation();
+ el.classList.remove('drag-over');
+ this.onFolderDrop(el.dataset.path);
+ },
+ handleNewItemClick(el, event) {
+ event.stopPropagation();
+ this.dropdownTargetFolder = el.dataset.path;
+ this.toggleNewDropdown(event);
+ },
+ handleRenameFolderClick(el, event) {
+ event.stopPropagation();
+ this.renameFolder(el.dataset.path, el.dataset.name);
+ },
+ handleDeleteFolderClick(el, event) {
+ event.stopPropagation();
+ this.deleteFolder(el.dataset.path, el.dataset.name);
+ },
+
+ // Item (note/media) handlers - read from dataset
+ handleItemClick(el) {
+ this.openItem(el.dataset.path, el.dataset.type);
+ },
+ handleItemHover(el, isEnter) {
+ const path = el.dataset.path;
+ if (path !== this.currentNote && path !== this.currentMedia) {
+ el.style.backgroundColor = isEnter ? 'var(--bg-hover)' : 'transparent';
+ }
+ },
+ handleDeleteItemClick(el, event) {
+ event.stopPropagation();
+ if (el.dataset.type === 'image') {
+ this.deleteMedia(el.dataset.path);
+ } else {
+ this.deleteNote(el.dataset.path, el.dataset.name);
+ }
+ },
+
+ // =====================================================================
+ // FOLDER TREE RENDERING
+ // =====================================================================
+
+ // Render folder recursively (helper for deep nesting)
+ // Uses data-* attributes to store path/name, avoiding JS string escaping issues
+ renderFolderRecursive(folder, level = 0, isTopLevel = false) {
+ if (!folder) return '';
+
+ let html = '';
+ const isExpanded = this.expandedFolders.has(folder.path);
+ const esc = (s) => this.escapeHtmlAttr(s); // Shorthand for HTML escaping
+
+ // Render this folder's header
+ // Note: Using native event handlers with data-* attributes instead of Alpine directives
+ // because x-html doesn't process Alpine directives in dynamically generated content
+ html += `
+
+
+
+
+
+ ${esc(folder.name)}
+ ${folder.notes.length === 0 && (!folder.children || Object.keys(folder.children).length === 0) ? `(${this.t('folders.empty')})` : ''}
+
+
+
+
+ `;
+
+ // If expanded, render folder contents (child folders + notes)
+ if (isExpanded) {
+ html += `
`;
+
+ // First, render child folders (if any)
+ if (folder.children && Object.keys(folder.children).length > 0) {
+ const children = Object.entries(folder.children)
+ .filter(([k, v]) => !this.hideUnderscoreFolders || !v.name.startsWith('_'))
+ .sort((a, b) => a[1].name.toLowerCase().localeCompare(b[1].name.toLowerCase()));
+
+ children.forEach(([childKey, childFolder]) => {
+ html += this.renderFolderRecursive(childFolder, 0, false);
+ });
+ }
+
+ // Then, render notes and images in this folder (after subfolders)
+ if (folder.notes && folder.notes.length > 0) {
+ folder.notes.forEach(note => {
+ html += this.renderNoteItem(note);
+ });
+ }
+
+ html += `
`; // Close folder-contents
+ }
+
+ html += `
`; // Close folder wrapper
+ return html;
+ },
+
+ // Render a single note/media item (used by both folders and root level)
+ renderNoteItem(note) {
+ const esc = (s) => this.escapeHtmlAttr(s);
+ const isMediaFile = note.type !== 'note';
+ const isCurrentNote = this.currentNote === note.path;
+ const isCurrentMedia = this.currentMedia === note.path;
+ const isCurrent = isMediaFile ? isCurrentMedia : isCurrentNote;
+
+ // Share icon for shared notes
+ const isShared = !isMediaFile && this.isNoteShared(note.path);
+ const shareIcon = isShared ? '' : '';
+ const icon = this.getMediaIcon(note.type);
+
+ return `
+
+
${shareIcon}${icon}${icon ? ' ' : ''}${esc(note.name)}
+
+
+ `;
+ },
+
+ // Render root-level items (notes and media not in any folder)
+ renderRootItems() {
+ const root = this.folderTree['__root__'];
+ if (!root || !root.notes || root.notes.length === 0) {
+ return '';
+ }
+ return root.notes.map(note => this.renderNoteItem(note)).join('');
+ },
+
+ // Toggle folder expansion
+ toggleFolder(folderPath) {
+ if (this.expandedFolders.has(folderPath)) {
+ this.expandedFolders.delete(folderPath);
+ } else {
+ this.expandedFolders.add(folderPath);
+ }
+ // Force Alpine reactivity by creating new Set reference
+ this.expandedFolders = new Set(this.expandedFolders);
+ },
+
+ // Check if folder is expanded
+ isFolderExpanded(folderPath) {
+ return this.expandedFolders.has(folderPath);
+ },
+
+ // Expand all folders
+ expandAllFolders() {
+ this.allFolders.forEach(folder => {
+ this.expandedFolders.add(folder);
+ });
+ // Force Alpine reactivity
+ this.expandedFolders = new Set(this.expandedFolders);
+ },
+
+ // Collapse all folders
+ collapseAllFolders() {
+ this.expandedFolders.clear();
+ // Force Alpine reactivity
+ this.expandedFolders = new Set(this.expandedFolders);
+ },
+
+ // Expand folder tree to show a specific note
+ expandFolderForNote(notePath) {
+ const parts = notePath.split('/');
+
+ // If note is in root, no folders to expand
+ if (parts.length <= 1) return;
+
+ // Remove the note name (last part)
+ parts.pop();
+
+ // Build and expand all parent folders
+ let currentPath = '';
+ parts.forEach((part, index) => {
+ currentPath = index === 0 ? part : `${currentPath}/${part}`;
+ this.expandedFolders.add(currentPath);
+ });
+
+ // Force Alpine reactivity
+ this.expandedFolders = new Set(this.expandedFolders);
+ },
+
+ // Scroll note into view in the sidebar navigation
+ scrollNoteIntoView(notePath) {
+ // Find the note element in the sidebar
+ // Use a slight delay to ensure DOM is fully rendered with Alpine bindings applied
+ setTimeout(() => {
+ const sidebar = document.querySelector('.flex-1.overflow-y-auto.custom-scrollbar');
+ if (!sidebar) return;
+
+ const noteElements = sidebar.querySelectorAll('.note-item');
+ let targetElement = null;
+ const noteName = notePath.split('/').pop().replace('.md', '');
+
+ // Find the element that corresponds to this note
+ noteElements.forEach(el => {
+ // Check if this is a note element (not folder) by checking if it has the note name
+ if (el.textContent.trim().startsWith(noteName) || el.textContent.includes(noteName)) {
+ // Check computed style to see if it's highlighted
+ const computedStyle = window.getComputedStyle(el);
+ const bgColor = computedStyle.backgroundColor;
+
+ // Check if background has the accent color (not transparent or default)
+ if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent' && !bgColor.includes('255, 255, 255')) {
+ targetElement = el;
+ }
+ }
+ });
+
+ // If found, scroll it into view
+ if (targetElement) {
+ targetElement.scrollIntoView({
+ behavior: 'smooth',
+ block: 'center',
+ inline: 'nearest'
+ });
+ }
+ }, 200); // Increased delay to ensure Alpine has finished rendering
+ },
+
+ // Unified drag and drop handlers for notes, folders, and media
+ onItemDragStart(itemPath, itemType, event) {
+ // Set unified drag state
+ this.draggedItem = { path: itemPath, type: itemType };
+
+ // Make drag image semi-transparent
+ if (event.target) {
+ event.target.style.opacity = '0.5';
+ }
+
+ event.dataTransfer.effectAllowed = 'all';
+ },
+
+ onItemDragEnd() {
+ this.draggedItem = null;
+ this.dropTarget = null;
+ this.dragOverFolder = null;
+ // Reset opacity of all draggable items
+ document.querySelectorAll('.note-item, .folder-header').forEach(el => el.style.opacity = '1');
+ // Reset drag-over class
+ document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
+ },
+
+
+ // Handle dragover on editor to show cursor position
+ onEditorDragOver(event) {
+ if (!this.draggedItem) return;
+
+ event.preventDefault();
+ this.dropTarget = 'editor';
+
+ // Focus the textarea
+ const textarea = event.target;
+ if (textarea.tagName !== 'TEXTAREA') return;
+
+ textarea.focus();
+
+ // Calculate cursor position from mouse coordinates
+ const pos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY);
+ if (pos >= 0) {
+ textarea.setSelectionRange(pos, pos);
+ }
+ },
+
+ // Calculate textarea cursor position from mouse coordinates
+ getTextareaCursorFromPoint(textarea, x, y) {
+ const rect = textarea.getBoundingClientRect();
+ const style = window.getComputedStyle(textarea);
+ const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2;
+ const paddingTop = parseFloat(style.paddingTop) || 0;
+ const paddingLeft = parseFloat(style.paddingLeft) || 0;
+
+ // Calculate which line we're on
+ const relativeY = y - rect.top - paddingTop + textarea.scrollTop;
+ const lineIndex = Math.max(0, Math.floor(relativeY / lineHeight));
+
+ // Split content into lines
+ const lines = textarea.value.split('\n');
+
+ // Find the character position at the start of this line
+ let charPos = 0;
+ for (let i = 0; i < Math.min(lineIndex, lines.length); i++) {
+ charPos += lines[i].length + 1; // +1 for newline
+ }
+
+ // If we're beyond the last line, position at end
+ if (lineIndex >= lines.length) {
+ return textarea.value.length;
+ }
+
+ // Approximate character position within the line based on X coordinate
+ const relativeX = x - rect.left - paddingLeft;
+ const charWidth = parseFloat(style.fontSize) * 0.6; // Approximate for monospace
+ const charInLine = Math.max(0, Math.floor(relativeX / charWidth));
+ const lineLength = lines[lineIndex]?.length || 0;
+
+ return charPos + Math.min(charInLine, lineLength);
+ },
+
+ // Handle dragenter on editor
+ onEditorDragEnter(event) {
+ if (!this.draggedItem) return;
+ event.preventDefault();
+ this.dropTarget = 'editor';
+ },
+
+ // Handle dragleave on editor
+ onEditorDragLeave(event) {
+ // Only clear dropTarget if we're actually leaving the editor
+ // (not just moving between child elements)
+ if (event.target.tagName === 'TEXTAREA') {
+ this.dropTarget = null;
+ }
+ },
+
+ // Handle drop into editor to create internal link or upload media
+ async onEditorDrop(event) {
+ event.preventDefault();
+ this.dropTarget = null;
+
+ // Check if files are being dropped (media from file system)
+ if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
+ await this.handleMediaDrop(event);
+ return;
+ }
+
+ // Otherwise, handle note/media link drop from sidebar
+ if (!this.draggedItem) return;
+
+ const notePath = this.draggedItem.path;
+ const isMediaFile = this.draggedItem.type !== 'note';
+
+ let link;
+ if (isMediaFile) {
+ // For media files (images, audio, video, PDF), use wiki-style embed link
+ const filename = notePath.split('/').pop();
+ link = `![[${filename}]]`;
+ } else {
+ // For notes, insert note link
+ const noteName = notePath.split('/').pop().replace('.md', '');
+ const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
+ link = `[${noteName}](${encodedPath})`;
+ }
+
+ // Insert at drop position
+ const textarea = event.target;
+ // Recalculate position from drop coordinates for accuracy
+ let cursorPos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY);
+ if (cursorPos < 0) cursorPos = textarea.selectionStart || 0;
+ const textBefore = this.noteContent.substring(0, cursorPos);
+ const textAfter = this.noteContent.substring(cursorPos);
+
+ this.noteContent = textBefore + link + textAfter;
+
+ // Move cursor after the link
+ this.$nextTick(() => {
+ textarea.selectionStart = textarea.selectionEnd = cursorPos + link.length;
+ textarea.focus();
+ });
+
+ // Trigger autosave
+ this.autoSave();
+
+ this.draggedItem = null;
+ },
+
+ // Handle media files dropped into editor
+ async handleMediaDrop(event) {
+ if (!this.currentNote) {
+ alert(this.t('notes.open_first'));
+ return;
+ }
+
+ const files = Array.from(event.dataTransfer.files);
+
+ // Filter for allowed media types
+ const allowedTypes = [
+ // Images
+ 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
+ // Audio
+ 'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/x-m4a',
+ // Video
+ 'video/mp4', 'video/webm', 'video/quicktime',
+ // Documents
+ 'application/pdf'
+ ];
+ const mediaFiles = files.filter(file => allowedTypes.includes(file.type.toLowerCase()));
+
+ if (mediaFiles.length === 0) {
+ alert(this.t('media.no_valid_files'));
+ return;
+ }
+
+ const textarea = event.target;
+ // Calculate cursor position from drop coordinates
+ let cursorPos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY);
+ if (cursorPos < 0) cursorPos = textarea.selectionStart || 0;
+
+ // Upload each media file
+ for (const file of mediaFiles) {
+ try {
+ const mediaPath = await this.uploadMedia(file, this.currentNote);
+ if (mediaPath) {
+ await this.insertMediaMarkdown(mediaPath, file.name, cursorPos);
+ }
+ } catch (error) {
+ ErrorHandler.handle(`upload file ${file.name}`, error);
+ }
+ }
+ },
+
+ // Upload a media file (image, audio, video, PDF)
+ async uploadMedia(file, notePath) {
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('note_path', notePath);
+
+ try {
+ const response = await fetch('/api/upload-media', {
+ method: 'POST',
+ body: formData
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || 'Upload failed');
+ }
+
+ const data = await response.json();
+ return data.path;
+ } catch (error) {
+ throw error;
+ }
+ },
+
+ // Insert media markdown at cursor position using wiki-style syntax
+ // This ensures media links don't break when notes are moved
+ 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();
+
+ // 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 altWithoutExt = altText.replace(/\.[^/.]+$/, '');
+
+ // If alt text is meaningful (not just "pasted-image"), include it
+ const markdown = (altWithoutExt && altWithoutExt !== filenameWithoutExt && !altWithoutExt.startsWith('pasted-image'))
+ ? `![[${filename}|${altWithoutExt}]]`
+ : `![[${filename}]]`;
+
+ // Reload notes FIRST to update image lookup maps before preview renders
+ await this.loadNotes();
+
+ const textBefore = this.noteContent.substring(0, cursorPos);
+ const textAfter = this.noteContent.substring(cursorPos);
+
+ this.noteContent = textBefore + markdown + '\n' + textAfter;
+
+ // Trigger autosave
+ this.autoSave();
+ },
+
+ // Handle paste event for clipboard media (images)
+ async handlePaste(event) {
+ if (!this.currentNote) return;
+
+ const items = event.clipboardData?.items;
+ if (!items) return;
+
+ for (const item of items) {
+ if (item.type.startsWith('image/')) {
+ event.preventDefault();
+
+ const blob = item.getAsFile();
+ if (blob) {
+ try {
+ const textarea = event.target;
+ const cursorPos = textarea.selectionStart || 0;
+
+ // Create a simple filename - backend will add timestamp to prevent collisions
+ const ext = item.type.split('/')[1] || 'png';
+ const filename = `pasted-image.${ext}`;
+
+ // Create a File from the blob
+ const file = new File([blob], filename, { type: item.type });
+
+ const mediaPath = await this.uploadMedia(file, this.currentNote);
+ if (mediaPath) {
+ await this.insertMediaMarkdown(mediaPath, filename, cursorPos);
+ }
+ } catch (error) {
+ ErrorHandler.handle('paste media', error);
+ }
+ }
+ break; // Only handle first media item
+ }
+ }
+ },
+
+ // Media type detection based on file extension
+ getMediaType(filename) {
+ if (!filename) return null;
+ const ext = filename.split('.').pop().toLowerCase();
+ const mediaTypes = {
+ image: ['jpg', 'jpeg', 'png', 'gif', 'webp'],
+ audio: ['mp3', 'wav', 'ogg', 'm4a'],
+ video: ['mp4', 'webm', 'mov', 'avi'],
+ document: ['pdf'],
+ };
+ for (const [type, extensions] of Object.entries(mediaTypes)) {
+ if (extensions.includes(ext)) return type;
+ }
+ return null;
+ },
+
+ // Get icon for media type
+ getMediaIcon(type) {
+ const icons = {
+ image: '๐ผ๏ธ',
+ audio: '๐ต',
+ video: '๐ฌ',
+ document: '๐',
+ };
+ return icons[type] || '';
+ },
+
+ // Open a note or media file (unified handler for sidebar/homepage clicks)
+ openItem(path, type = 'note', searchHighlight = '') {
+ this.showGraph = false;
+ // Check if it's a media file by type or extension
+ const mediaType = type !== 'note' ? type : this.getMediaType(path);
+ if (mediaType && mediaType !== 'note') {
+ this.viewMedia(path, mediaType);
+ } else {
+ this.loadNote(path, true, searchHighlight);
+ }
+ },
+
+ // View a media file (image, audio, video, PDF) in the main pane
+ viewMedia(mediaPath, mediaType = null, updateHistory = true) {
+ this.showGraph = false; // Ensure graph is closed
+ this.currentNote = '';
+ this.currentNoteName = '';
+ this.noteContent = '';
+ this.currentMedia = mediaPath; // Reuse currentMedia for all media
+ this.currentMediaType = mediaType || this.getMediaType(mediaPath) || 'image';
+ this.shareInfo = null; // Reset share info
+ this.viewMode = 'preview'; // Use preview mode to show media
+
+ // Update browser tab title
+ const fileName = mediaPath.split('/').pop();
+ document.title = `${fileName} - ${this.appName}`;
+
+ // Expand folder tree to show the media file
+ this.expandFolderForNote(mediaPath);
+
+ // Update browser URL
+ if (updateHistory) {
+ // Encode each path segment to handle special characters
+ const encodedPath = mediaPath.split('/').map(segment => encodeURIComponent(segment)).join('/');
+ window.history.pushState(
+ { mediaPath: mediaPath },
+ '',
+ `/${encodedPath}`
+ );
+ }
+ },
+
+ // Backward compatibility alias
+ viewImage(mediaPath, updateHistory = true) {
+ this.viewMedia(mediaPath, 'image', updateHistory);
+ },
+
+ // Delete a media file (image, audio, video, PDF)
+ async deleteMedia(mediaPath) {
+ const filename = mediaPath.split('/').pop();
+ if (!confirm(this.t('media.confirm_delete', { name: filename }))) return;
+
+ try {
+ const response = await fetch(`/api/notes/${encodeURIComponent(mediaPath)}`, {
+ method: 'DELETE'
+ });
+
+ 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) {
+ ErrorHandler.handle('delete media', error);
+ }
+ },
+
+ // Handle clicks on internal links in preview
+ handleInternalLink(event) {
+ // Check if clicked element is a link
+ const link = event.target.closest('a');
+ if (!link) return;
+
+ const href = link.getAttribute('href');
+ if (!href) return;
+
+ // Check if it's an external link or API path (media files, etc.)
+ if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//') || href.startsWith('mailto:') || href.startsWith('/api/')) {
+ return; // Let external links and API paths work normally
+ }
+
+ // Prevent default navigation for internal links
+ event.preventDefault();
+
+ // Parse href into note path and anchor (e.g., "note.md#section" -> notePath="note.md", anchor="section")
+ const decodedHref = decodeURIComponent(href);
+ const hashIndex = decodedHref.indexOf('#');
+ const notePath = hashIndex !== -1 ? decodedHref.substring(0, hashIndex) : decodedHref;
+ const anchor = hashIndex !== -1 ? decodedHref.substring(hashIndex + 1) : null;
+
+ // If it's just an anchor link (#heading), scroll within current note
+ if (!notePath && anchor) {
+ this.scrollToAnchor(anchor);
+ return;
+ }
+
+ // Skip if no path
+ if (!notePath) return;
+
+ // Find the note by path (try exact match first, then with .md extension)
+ let targetNote = this.notes.find(n =>
+ n.path === notePath ||
+ n.path === notePath + '.md'
+ );
+
+ if (!targetNote) {
+ // Try to find by name (in case link uses just the note name without path)
+ targetNote = this.notes.find(n =>
+ n.name === notePath ||
+ n.name === notePath + '.md' ||
+ n.name.toLowerCase() === notePath.toLowerCase() ||
+ n.name.toLowerCase() === (notePath + '.md').toLowerCase()
+ );
+ }
+
+ if (!targetNote) {
+ // Last resort: case-insensitive path matching
+ targetNote = this.notes.find(n =>
+ n.path.toLowerCase() === notePath.toLowerCase() ||
+ n.path.toLowerCase() === (notePath + '.md').toLowerCase()
+ );
+ }
+
+ if (targetNote) {
+ // Load the note, then scroll to anchor if present
+ this.loadNote(targetNote.path).then(() => {
+ if (anchor) {
+ // Small delay to ensure content is rendered
+ setTimeout(() => this.scrollToAnchor(anchor), 100);
+ }
+ });
+ } else if (confirm(this.t('notes.create_from_link', { path: notePath }))) {
+ // Note doesn't exist - create it (reuses createNote with duplicate check)
+ this.createNote(null, notePath);
+ }
+ },
+
+ // Scroll to an anchor (heading) by slug - reuses outline data
+ scrollToAnchor(anchor) {
+ // Normalize the anchor (GitHub-style slug)
+ const targetSlug = anchor
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, '')
+ .replace(/\s+/g, '-')
+ .replace(/-+/g, '-');
+
+ // Find matching heading in outline
+ const heading = this.outline.find(h => h.slug === targetSlug);
+
+ if (heading) {
+ this.scrollToHeading(heading);
+ } else {
+ // Fallback: try to find heading by exact text match
+ const headingByText = this.outline.find(h =>
+ h.text.toLowerCase().replace(/\s+/g, '-') === anchor.toLowerCase()
+ );
+ if (headingByText) {
+ this.scrollToHeading(headingByText);
+ }
+ }
+ },
+
+
+ cancelDrag() {
+ // Cancel any active drag operation (triggered by ESC key)
+ this.draggedItem = null;
+ this.dropTarget = null;
+ this.dragOverFolder = null;
+ // Reset styles - only query elements with drag-over class (more efficient)
+ document.querySelectorAll('.folder-item').forEach(el => el.style.opacity = '1');
+ document.querySelectorAll('.note-item').forEach(el => el.style.opacity = '1');
+ document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
+ },
+
+ async onFolderDrop(targetFolderPath) {
+ // Ignore if we're dropping into the editor
+ if (this.dropTarget === 'editor') {
+ return;
+ }
+
+ // Capture dragged item info immediately (ondragend may clear it)
+ if (!this.draggedItem) return;
+ const { path: draggedPath, type: draggedType } = this.draggedItem;
+
+ // Determine item category for endpoint selection
+ const isFolder = draggedType === 'folder';
+ const isNote = draggedType === 'note';
+ const isMedia = !isFolder && !isNote; // image, audio, video, document
+
+ // Handle folder drop
+ if (isFolder) {
+ // Prevent dropping folder into itself or its subfolders
+ if (targetFolderPath === draggedPath ||
+ targetFolderPath.startsWith(draggedPath + '/')) {
+ alert(this.t('folders.cannot_move_into_self'));
+ return;
+ }
+
+ const folderName = draggedPath.split('/').pop();
+ const newPath = targetFolderPath ? `${targetFolderPath}/${folderName}` : folderName;
+
+ if (newPath === draggedPath) return;
+
+ // Capture favorites info before async call
+ const oldPrefix = draggedPath + '/';
+ const newPrefix = newPath + '/';
+
+ try {
+ const response = await fetch('/api/folders/move', {
+ 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));
+ if (favoritesInFolder.length > 0) {
+ const newFavorites = this.favorites.map(f =>
+ f.startsWith(oldPrefix) ? newPrefix + f.substring(oldPrefix.length) : f
+ );
+ this.favorites = newFavorites;
+ this.favoritesSet = new Set(newFavorites);
+ this.saveFavorites();
+ }
+
+ // Keep folder expanded if it was
+ const wasExpanded = this.expandedFolders.has(draggedPath);
+
+ await this.loadNotes();
+ await this.loadSharedNotePaths();
+
+ if (wasExpanded) {
+ this.expandedFolders.delete(draggedPath);
+ this.expandedFolders.add(newPath);
+ this.saveExpandedFolders();
+ }
+ } else {
+ const errorData = await response.json().catch(() => ({}));
+ alert(errorData.detail || this.t('move.failed_folder'));
+ }
+ } catch (error) {
+ console.error('Failed to move folder:', error);
+ alert(this.t('move.failed_folder'));
+ }
+ return;
+ }
+
+ // Handle note or media drop into folder
+ const item = this.notes.find(n => n.path === draggedPath);
+ if (!item) return;
+
+ const filename = draggedPath.split('/').pop();
+ const newPath = targetFolderPath ? `${targetFolderPath}/${filename}` : filename;
+
+ if (newPath === draggedPath) return;
+
+ // Check if note is favorited (only for notes)
+ const wasFavorited = isNote && this.favoritesSet.has(draggedPath);
+
+ try {
+ // Use different endpoint for media vs notes
+ const endpoint = isMedia ? '/api/media/move' : '/api/notes/move';
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ oldPath: draggedPath, newPath })
+ });
+
+ 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 errorKey = isMedia ? 'move.failed_media' : 'move.failed_note';
+ alert(errorData.detail || this.t(errorKey));
+ }
+ } catch (error) {
+ console.error(`Failed to move ${isMedia ? 'media' : 'note'}:`, error);
+ const errorKey = isMedia ? 'move.failed_media' : 'move.failed_note';
+ alert(this.t(errorKey));
+ }
+ },
+
+
+ // Load a specific note
+ async loadNote(notePath, updateHistory = true, searchQuery = '') {
+ try {
+ // Close mobile sidebar when a note is selected
+ this.mobileSidebarOpen = false;
+
+ const response = await fetch(`/api/notes/${notePath}`);
+
+ // Check if note exists
+ if (!response.ok) {
+ if (response.status === 404) {
+ // Note not found - silently redirect to home
+ window.history.replaceState({ homepageFolder: this.selectedHomepageFolder || '' }, '', '/');
+ this.currentNote = '';
+ this.noteContent = '';
+ this.currentMedia = '';
+ document.title = this.appName;
+ return;
+ }
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ this.currentNote = notePath;
+ this._lastRenderedContent = ''; // Clear render cache for new note
+ this._cachedRenderedHTML = '';
+ this._initializedVideoSources = new Set(); // Clear video cache for new note
+ this.noteContent = data.content;
+ this.currentNoteName = notePath.split('/').pop().replace('.md', '');
+ this.currentMedia = ''; // Clear image viewer when loading a note
+ this.shareInfo = null; // Reset share info for new note
+
+ // Update browser tab title
+ document.title = `${this.currentNoteName} - ${this.appName}`;
+ this.lastSaved = false;
+
+ // Extract outline for TOC panel
+ this.extractOutline(data.content);
+
+ // Store backlinks from API response
+ this.backlinks = data.backlinks || [];
+
+ // Initialize undo/redo history for this note (with cursor at start)
+ this.undoHistory = [{ content: data.content, cursorPos: 0 }];
+ this.redoHistory = [];
+ this.hasPendingHistoryChanges = false;
+
+ // Update browser URL and history
+ if (updateHistory) {
+ // Encode the path properly (spaces become %20, etc.)
+ const pathWithoutExtension = notePath.replace('.md', '');
+ // Encode each path segment to handle special characters
+ const encodedPath = pathWithoutExtension.split('/').map(segment => encodeURIComponent(segment)).join('/');
+ let url = `/${encodedPath}`;
+ // Add search query parameter if present
+ if (searchQuery) {
+ url += `?search=${encodeURIComponent(searchQuery)}`;
+ }
+ window.history.pushState(
+ {
+ notePath: notePath,
+ searchQuery: searchQuery,
+ homepageFolder: this.selectedHomepageFolder || '' // Save current folder state
+ },
+ '',
+ url
+ );
+ }
+
+ // Calculate stats if plugin enabled
+ if (this.statsPluginEnabled) {
+ this.calculateStats();
+ }
+
+ // Parse frontmatter metadata
+ this.parseMetadata();
+
+ // Store search query for highlighting
+ if (searchQuery) {
+ this.currentSearchHighlight = searchQuery;
+ } else {
+ // Clear highlights if no search query
+ this.currentSearchHighlight = '';
+ }
+
+ // Expand folder tree to show the loaded note
+ this.expandFolderForNote(notePath);
+
+ // Use $nextTick twice to ensure Alpine.js has time to:
+ // 1. First tick: expand folders and update DOM
+ // 2. Second tick: highlight the note and setup everything else
+ this.$nextTick(() => {
+ this.$nextTick(() => {
+ this.refreshDOMCache();
+ this.setupScrollSync();
+ this.scrollToTop();
+
+ // Apply or clear search highlighting
+ if (searchQuery) {
+ // Pass true to focus editor when loading from search result
+ this.highlightSearchTerm(searchQuery, true);
+ } else {
+ this.clearSearchHighlights();
+ }
+
+ // Scroll note into view in sidebar if needed
+ this.scrollNoteIntoView(notePath);
+ });
+ });
+
+ } catch (error) {
+ ErrorHandler.handle('load note', error);
+ }
+ },
+
+ // Load item (note or media) from URL path
+ loadItemFromURL() {
+ // Get path from URL (e.g., /folder/note or /folder/image.png)
+ let path = window.location.pathname;
+
+ // Strip .md extension if present (for MKdocs/Zensical integration)
+ if (path.toLowerCase().endsWith('.md')) {
+ path = path.slice(0, -3);
+ // Update URL bar to show clean path without .md
+ window.history.replaceState(null, '', path);
+ }
+
+ // Skip if root path or static assets
+ if (path === '/' || path.startsWith('/static/') || path.startsWith('/api/')) {
+ return;
+ }
+
+ // Remove leading slash and decode URL encoding (e.g., %20 -> space)
+ const decodedPath = decodeURIComponent(path.substring(1));
+
+ // Check if this is a media file (image, audio, video, PDF)
+ const matchedItem = this.notes.find(n => n.path === decodedPath);
+
+ if (matchedItem && matchedItem.type !== 'note') {
+ // It's a media file, view it
+ this.viewMedia(decodedPath, matchedItem.type, false); // false = don't update history
+ } else {
+ // It's a note, add .md extension and load it
+ const notePath = decodedPath + '.md';
+
+ // Parse query string for search parameter
+ const urlParams = new URLSearchParams(window.location.search);
+ const searchParam = urlParams.get('search');
+
+ // Try to load the note directly - the backend will handle 404 if it doesn't exist
+ // This is more robust than checking the frontend notes list
+ this.loadNote(notePath, false, searchParam || '');
+
+ // If there's a search parameter, populate the search box and trigger search
+ if (searchParam) {
+ this.searchQuery = searchParam;
+ // Trigger search to populate results list
+ this.searchNotes();
+ }
+ }
+ },
+
+ // Highlight search term in editor and preview
+ highlightSearchTerm(query, focusEditor = false) {
+ if (!query || !query.trim()) {
+ this.clearSearchHighlights();
+ return;
+ }
+
+ const searchTerm = query.trim();
+
+ // Highlight in editor (textarea)
+ this.highlightInEditor(searchTerm, focusEditor);
+
+ // Highlight in preview (rendered HTML)
+ this.highlightInPreview(searchTerm);
+ },
+
+ // Highlight search term in the editor textarea
+ highlightInEditor(searchTerm, shouldFocus = false) {
+ const editor = this._domCache.editor || document.getElementById('editor');
+ if (!editor) return;
+
+ // For textarea, we can't directly highlight text, but we can scroll to first match
+ const content = editor.value;
+ const lowerContent = content.toLowerCase();
+ const lowerTerm = searchTerm.toLowerCase();
+ const index = lowerContent.indexOf(lowerTerm);
+
+ if (index !== -1) {
+ // Calculate line number to scroll to
+ const textBefore = content.substring(0, index);
+ const lineNumber = textBefore.split('\n').length;
+
+ // Scroll to approximate position
+ const lineHeight = 20; // Approximate line height in pixels
+ editor.scrollTop = (lineNumber - 5) * lineHeight; // Scroll a bit above to show context
+
+ // Only focus and select if explicitly requested (e.g., from search result click)
+ if (shouldFocus) {
+ editor.focus();
+ editor.setSelectionRange(index, index + searchTerm.length);
+
+ // Blur immediately so the selection stays visible but editor isn't focused
+ setTimeout(() => editor.blur(), 100);
+ }
+ }
+ },
+
+ // Highlight search term in the preview pane
+ highlightInPreview(searchTerm) {
+ const preview = document.querySelector('.markdown-preview');
+ if (!preview) return;
+
+ // Remove existing highlights
+ this.clearSearchHighlights();
+
+ // Create a tree walker to find all text nodes
+ const walker = document.createTreeWalker(
+ preview,
+ NodeFilter.SHOW_TEXT,
+ null,
+ false
+ );
+
+ const textNodes = [];
+ let node;
+ while (node = walker.nextNode()) {
+ // Skip code blocks and pre tags
+ if (node.parentElement.tagName === 'CODE' ||
+ node.parentElement.tagName === 'PRE') {
+ continue;
+ }
+ textNodes.push(node);
+ }
+
+ const lowerTerm = searchTerm.toLowerCase();
+ let matchIndex = 0;
+
+ // Highlight matches in text nodes
+ textNodes.forEach(textNode => {
+ const text = textNode.textContent;
+ const lowerText = text.toLowerCase();
+
+ if (lowerText.includes(lowerTerm)) {
+ const fragment = document.createDocumentFragment();
+ let lastIndex = 0;
+ let index;
+
+ while ((index = lowerText.indexOf(lowerTerm, lastIndex)) !== -1) {
+ // Add text before match
+ if (index > lastIndex) {
+ fragment.appendChild(
+ document.createTextNode(text.substring(lastIndex, index))
+ );
+ }
+
+ // Add highlighted match
+ const mark = document.createElement('mark');
+ mark.className = 'search-highlight';
+ mark.setAttribute('data-match-index', matchIndex);
+ mark.textContent = text.substring(index, index + searchTerm.length);
+
+ // First match is active (styled via CSS)
+ if (matchIndex === 0) {
+ mark.classList.add('active-match');
+ }
+
+ fragment.appendChild(mark);
+ matchIndex++;
+
+ lastIndex = index + searchTerm.length;
+ }
+
+ // Add remaining text
+ if (lastIndex < text.length) {
+ fragment.appendChild(
+ document.createTextNode(text.substring(lastIndex))
+ );
+ }
+
+ // Replace text node with highlighted fragment
+ textNode.parentNode.replaceChild(fragment, textNode);
+ }
+ });
+
+ // Update total matches and reset current index
+ this.totalMatches = matchIndex;
+ this.currentMatchIndex = matchIndex > 0 ? 0 : -1;
+
+ // Scroll to first match
+ if (this.totalMatches > 0) {
+ this.scrollToMatch(0);
+ }
+ },
+
+ // Navigate to next search match
+ nextMatch() {
+ if (this.totalMatches === 0) return;
+
+ this.currentMatchIndex = (this.currentMatchIndex + 1) % this.totalMatches;
+ this.scrollToMatch(this.currentMatchIndex);
+ },
+
+ // Navigate to previous search match
+ previousMatch() {
+ if (this.totalMatches === 0) return;
+
+ this.currentMatchIndex = (this.currentMatchIndex - 1 + this.totalMatches) % this.totalMatches;
+ this.scrollToMatch(this.currentMatchIndex);
+ },
+
+ // Scroll to a specific match index
+ scrollToMatch(index) {
+ const preview = document.querySelector('.markdown-preview');
+ if (!preview) return;
+
+ const allMatches = preview.querySelectorAll('mark.search-highlight');
+ if (index < 0 || index >= allMatches.length) return;
+
+ // Update styling - make current match prominent (via CSS class)
+ allMatches.forEach((mark, i) => {
+ mark.classList.toggle('active-match', i === index);
+ });
+
+ // Scroll to the match
+ const targetMatch = allMatches[index];
+ const previewContainer = this._domCache.previewContainer;
+ if (previewContainer && targetMatch) {
+ const elementTop = targetMatch.offsetTop;
+ previewContainer.scrollTop = elementTop - 100; // Scroll with some offset
+ }
+ },
+
+ // Clear search highlights
+ clearSearchHighlights() {
+ const preview = document.querySelector('.markdown-preview');
+ if (!preview) return;
+
+ const highlights = preview.querySelectorAll('mark.search-highlight');
+ highlights.forEach(mark => {
+ const text = document.createTextNode(mark.textContent);
+ mark.parentNode.replaceChild(text, mark);
+ });
+
+ // Normalize text nodes to merge adjacent text nodes
+ preview.normalize();
+
+ // Reset match counters
+ this.totalMatches = 0;
+ this.currentMatchIndex = -1;
+ },
+
+ // =====================================================
+ // DROPDOWN MENU SYSTEM
+ // =====================================================
+
+ toggleNewDropdown(event) {
+ this.showNewDropdown = true; // Always open (or keep open)
+
+ if (event && event.target) {
+ const rect = event.target.getBoundingClientRect();
+ // Position dropdown next to the clicked element
+ let top = rect.bottom + 4; // 4px spacing
+ let left = rect.left;
+
+ // Keep dropdown on screen
+ const dropdownWidth = 200;
+ const dropdownHeight = 150;
+ if (left + dropdownWidth > window.innerWidth) {
+ left = rect.right - dropdownWidth;
+ }
+ if (top + dropdownHeight > window.innerHeight) {
+ top = rect.top - dropdownHeight - 4;
+ }
+
+ this.dropdownPosition = { top, left };
+ }
+ },
+
+ closeDropdown() {
+ this.showNewDropdown = false;
+ this.dropdownTargetFolder = null; // Reset folder context
+ },
+
+ // =====================================================
+ // UNIFIED CREATION FUNCTIONS (reusable from anywhere)
+ // =====================================================
+
+ // Switch to split view (if in preview-only mode) and focus editor for new notes
+ focusEditorForNewNote() {
+ // Only switch if in preview-only mode - don't disturb edit or split mode
+ if (this.viewMode === 'preview') {
+ this.viewMode = 'split';
+ this.saveViewMode();
+ }
+ // Focus the editor after a short delay to ensure DOM is updated
+ this.$nextTick(() => {
+ const editor = document.getElementById('note-editor');
+ if (editor) editor.focus();
+ });
+ },
+
+ async createNote(folderPath = null, directPath = null) {
+ let notePath;
+
+ if (directPath) {
+ // Direct path provided (e.g., from wiki link) - skip prompting
+ notePath = directPath.endsWith('.md') ? directPath : `${directPath}.md`;
+ } else {
+ // Use provided folder path, or dropdown target folder context, or homepage folder
+ // Note: Check dropdownTargetFolder !== null to distinguish between '' (root) and not set
+ let targetFolder;
+ if (folderPath !== null) {
+ targetFolder = folderPath;
+ } else if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) {
+ targetFolder = this.dropdownTargetFolder; // Can be '' for root or a folder path
+ } else {
+ targetFolder = this.selectedHomepageFolder || '';
+ }
+ this.closeDropdown();
+
+ const promptText = targetFolder
+ ? this.t('notes.prompt_name_in_folder', { folder: targetFolder })
+ : this.t('notes.prompt_name_with_path');
+
+ const noteName = prompt(promptText);
+ if (!noteName) return;
+
+ // Validate the name/path (may contain / for paths when no target folder)
+ const validation = targetFolder
+ ? FilenameValidator.validateFilename(noteName)
+ : FilenameValidator.validatePath(noteName);
+
+ if (!validation.valid) {
+ alert(this.getValidationErrorMessage(validation, 'note'));
+ return;
+ }
+
+ const validatedName = validation.sanitized;
+
+ if (targetFolder) {
+ notePath = `${targetFolder}/${validatedName}.md`;
+ } else {
+ notePath = validatedName.endsWith('.md') ? validatedName : `${validatedName}.md`;
+ }
+ }
+
+ // CRITICAL: Check if note already exists (applies to both prompt and direct path)
+ const existingNote = this.notes.find(note => note.path === notePath);
+ if (existingNote) {
+ alert(this.t('notes.already_exists', { name: notePath }));
+ return;
+ }
+
+ try {
+ const response = await fetch(`/api/notes/${notePath}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ content: '' })
+ });
+
+ if (response.ok) {
+ // Expand parent folder if note is in a subfolder
+ const folderPart = notePath.includes('/') ? notePath.substring(0, notePath.lastIndexOf('/')) : '';
+ if (folderPart) this.expandedFolders.add(folderPart);
+ await this.loadNotes();
+ await this.loadNote(notePath);
+ this.focusEditorForNewNote();
+ } else {
+ ErrorHandler.handle('create note', new Error('Server returned error'));
+ }
+ } catch (error) {
+ ErrorHandler.handle('create note', error);
+ }
+ },
+
+ async createFolder(parentPath = null) {
+ // Use provided parent path, or dropdown target folder context, or homepage folder
+ // Note: Check dropdownTargetFolder !== null to distinguish between '' (root) and not set
+ let targetFolder;
+ if (parentPath !== null) {
+ targetFolder = parentPath;
+ } else if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) {
+ targetFolder = this.dropdownTargetFolder; // Can be '' for root or a folder path
+ } else {
+ targetFolder = this.selectedHomepageFolder || '';
+ }
+ this.closeDropdown();
+
+ const promptText = targetFolder
+ ? this.t('folders.prompt_name_in_folder', { folder: targetFolder })
+ : this.t('folders.prompt_name_with_path');
+
+ const folderName = prompt(promptText);
+ if (!folderName) return;
+
+ // Validate the name/path (may contain / for paths when no target folder)
+ const validation = targetFolder
+ ? FilenameValidator.validateFilename(folderName)
+ : FilenameValidator.validatePath(folderName);
+
+ if (!validation.valid) {
+ alert(this.getValidationErrorMessage(validation, 'folder'));
+ return;
+ }
+
+ const validatedName = validation.sanitized;
+ const folderPath = targetFolder ? `${targetFolder}/${validatedName}` : validatedName;
+
+ // Check if folder already exists
+ const existingFolder = this.allFolders.find(folder => folder === folderPath);
+ if (existingFolder) {
+ alert(this.t('folders.already_exists', { name: validatedName }));
+ return;
+ }
+
+ try {
+ const response = await fetch('/api/folders', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ path: folderPath })
+ });
+
+ if (response.ok) {
+ if (targetFolder) {
+ this.expandedFolders.add(targetFolder);
+ }
+ this.expandedFolders.add(folderPath);
+ await this.loadNotes();
+
+ // Navigate to the newly created folder on the homepage
+ this.goToHomepageFolder(folderPath);
+ } else {
+ ErrorHandler.handle('create folder', new Error('Server returned error'));
+ }
+ } catch (error) {
+ ErrorHandler.handle('create folder', error);
+ }
+ },
+
+ // Rename a folder
+ async renameFolder(folderPath, currentName) {
+ const newName = prompt(this.t('folders.prompt_rename', { name: currentName }), currentName);
+ if (!newName || newName === currentName) return;
+
+ // Validate the new name (single segment, no path separators)
+ const validation = FilenameValidator.validateFilename(newName);
+ if (!validation.valid) {
+ alert(this.getValidationErrorMessage(validation, 'folder'));
+ return;
+ }
+
+ const validatedName = validation.sanitized;
+
+ // Calculate new path
+ const pathParts = folderPath.split('/');
+ pathParts[pathParts.length - 1] = validatedName;
+ const newPath = pathParts.join('/');
+
+ 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) {
+ // Update expanded folders state
+ if (this.expandedFolders.has(folderPath)) {
+ this.expandedFolders.delete(folderPath);
+ this.expandedFolders.add(newPath);
+ }
+
+ // Update favorites that were in the renamed folder
+ const folderPrefix = folderPath + '/';
+ const newFolderPrefix = newPath + '/';
+ const newFavorites = this.favorites.map(f => {
+ if (f.startsWith(folderPrefix)) {
+ return f.replace(folderPrefix, newFolderPrefix);
+ }
+ return f;
+ });
+ // Check if anything changed
+ if (JSON.stringify(newFavorites) !== JSON.stringify(this.favorites)) {
+ this.favorites = newFavorites;
+ this.favoritesSet = new Set(newFavorites);
+ this.saveFavorites();
+ }
+
+ // Update current note path if it's in the renamed folder
+ if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
+ this.currentNote = this.currentNote.replace(folderPrefix, newFolderPrefix);
+ }
+
+ await this.loadNotes();
+ } else {
+ ErrorHandler.handle('rename folder', new Error('Server returned error'));
+ }
+ } catch (error) {
+ ErrorHandler.handle('rename folder', error);
+ }
+ },
+
+ // Delete folder
+ async deleteFolder(folderPath, folderName) {
+ const confirmation = confirm(this.t('folders.confirm_delete', { name: folderName }));
+
+ if (!confirmation) 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 newFavorites = this.favorites.filter(f => !f.startsWith(folderPrefix));
+ if (newFavorites.length !== this.favorites.length) {
+ this.favorites = newFavorites;
+ this.favoritesSet = new Set(newFavorites);
+ this.saveFavorites();
+ }
+
+ // Clear current note if it was in the deleted folder
+ if (this.currentNote && this.currentNote.startsWith(folderPrefix)) {
+ this.currentNote = '';
+ this.noteContent = '';
+ document.title = this.appName;
+ }
+
+ await this.loadNotes();
+ } else {
+ ErrorHandler.handle('delete folder', new Error('Server returned error'));
+ }
+ } catch (error) {
+ ErrorHandler.handle('delete folder', error);
+ }
+ },
+
+ // Auto-save with debounce
+ autoSave() {
+ if (this.saveTimeout) {
+ clearTimeout(this.saveTimeout);
+ }
+
+ this.lastSaved = false;
+
+ // Push to undo history (but not during undo/redo operations)
+ if (!this.isUndoRedo) {
+ this.pushToHistory();
+ }
+
+ // Calculate stats in real-time if plugin enabled
+ if (this.statsPluginEnabled) {
+ this.calculateStats();
+ }
+
+ // Parse metadata in real-time
+ this.parseMetadata();
+
+ // Update outline (TOC) in real-time
+ this.extractOutline(this.noteContent);
+
+ this.saveTimeout = setTimeout(() => {
+ // Commit to undo history when autosave triggers (same debounce timing)
+ if (this.hasPendingHistoryChanges) {
+ this.commitToHistory();
+ }
+ this.saveNote();
+ }, CONFIG.AUTOSAVE_DELAY);
+ },
+
+ // Mark that we have pending changes (called on each keystroke)
+ pushToHistory() {
+ this.hasPendingHistoryChanges = true;
+ },
+
+ // Immediately commit pending changes to history (call before undo/redo)
+ flushHistory() {
+ if (this.hasPendingHistoryChanges) {
+ this.commitToHistory();
+ }
+ },
+
+ // Actually commit to undo history (internal)
+ commitToHistory() {
+ const editor = document.getElementById('note-editor');
+ const cursorPos = editor ? editor.selectionStart : 0;
+
+ // Only push if content actually changed from last history entry
+ if (this.undoHistory.length > 0 &&
+ this.undoHistory[this.undoHistory.length - 1].content === this.noteContent) {
+ this.hasPendingHistoryChanges = false;
+ return;
+ }
+
+ this.undoHistory.push({ content: this.noteContent, cursorPos });
+
+ // Limit history size
+ if (this.undoHistory.length > this.maxHistorySize) {
+ this.undoHistory.shift();
+ }
+
+ // Clear redo history when new change is made
+ this.redoHistory = [];
+ this.hasPendingHistoryChanges = false;
+ },
+
+ // Undo last change
+ undo() {
+ if (!this.currentNote) return;
+
+ // Flush any pending history changes first (so we don't lose unsaved edits)
+ this.flushHistory();
+
+ if (this.undoHistory.length <= 1) return;
+
+ const editor = document.getElementById('note-editor');
+
+ // Pop current state to redo history
+ const currentState = this.undoHistory.pop();
+ this.redoHistory.push(currentState);
+
+ // Get previous state
+ const previousState = this.undoHistory[this.undoHistory.length - 1];
+
+ // Apply previous state
+ this.isUndoRedo = true;
+ this.noteContent = previousState.content;
+
+ // Recalculate stats with new content
+ if (this.statsPluginEnabled) {
+ this.calculateStats();
+ }
+
+ // Restore cursor position from the state we're going back to
+ this.$nextTick(() => {
+ this.saveNote();
+ this.isUndoRedo = false;
+ if (editor) {
+ setTimeout(() => {
+ const newPos = Math.min(previousState.cursorPos, this.noteContent.length);
+ editor.setSelectionRange(newPos, newPos);
+ editor.focus();
+ }, 0);
+ }
+ });
+ },
+
+ // Redo last undone change
+ redo() {
+ if (!this.currentNote) return;
+
+ // Flush any pending history changes first
+ this.flushHistory();
+
+ if (this.redoHistory.length === 0) return;
+
+ const editor = document.getElementById('note-editor');
+
+ // Pop from redo history
+ const nextState = this.redoHistory.pop();
+
+ // Push to undo history
+ this.undoHistory.push(nextState);
+
+ // Apply next state
+ this.isUndoRedo = true;
+ this.noteContent = nextState.content;
+
+ // Recalculate stats with new content
+ if (this.statsPluginEnabled) {
+ this.calculateStats();
+ }
+
+ // Restore cursor position from the state we're going forward to
+ this.$nextTick(() => {
+ this.saveNote();
+ this.isUndoRedo = false;
+ if (editor) {
+ setTimeout(() => {
+ const newPos = Math.min(nextState.cursorPos, this.noteContent.length);
+ editor.setSelectionRange(newPos, newPos);
+ editor.focus();
+ }, 0);
+ }
+ });
+ },
+
+ // Markdown formatting helpers
+ wrapSelection(before, after, placeholder) {
+ const editor = document.getElementById('note-editor');
+ if (!editor) return;
+
+ const start = editor.selectionStart;
+ const end = editor.selectionEnd;
+ const selectedText = this.noteContent.substring(start, end);
+ const textToWrap = selectedText || placeholder;
+
+ // Build the new text
+ const newText = before + textToWrap + after;
+
+ // Update content
+ this.noteContent = this.noteContent.substring(0, start) + newText + this.noteContent.substring(end);
+
+ // Set cursor position (select the wrapped text or placeholder)
+ this.$nextTick(() => {
+ if (selectedText) {
+ // If text was selected, keep it selected (inside the wrapper)
+ editor.setSelectionRange(start + before.length, start + before.length + selectedText.length);
+ } else {
+ // If no text selected, select the placeholder
+ editor.setSelectionRange(start + before.length, start + before.length + placeholder.length);
+ }
+ editor.focus();
+ });
+
+ // Trigger autosave
+ this.autoSave();
+ },
+
+ insertLink() {
+ const editor = document.getElementById('note-editor');
+ if (!editor) return;
+
+ const start = editor.selectionStart;
+ const end = editor.selectionEnd;
+ const selectedText = this.noteContent.substring(start, end);
+
+ // If text is selected, use it as link text; otherwise use placeholder
+ const linkText = selectedText || 'link text';
+ const linkUrl = 'url';
+
+ // Build the markdown link
+ const newText = `[${linkText}](${linkUrl})`;
+
+ // Update content
+ this.noteContent = this.noteContent.substring(0, start) + newText + this.noteContent.substring(end);
+
+ // Set cursor position to select the URL part for easy editing
+ this.$nextTick(() => {
+ const urlStart = start + linkText.length + 3; // After "[linkText]("
+ const urlEnd = urlStart + linkUrl.length;
+ editor.setSelectionRange(urlStart, urlEnd);
+ editor.focus();
+ });
+
+ // Trigger autosave
+ this.autoSave();
+ },
+
+ // Insert a markdown table placeholder
+ insertTable() {
+ const editor = document.getElementById('note-editor');
+ if (!editor) return;
+
+ const cursorPos = editor.selectionStart;
+
+ // Basic 3x3 table placeholder
+ const table = `| Header 1 | Header 2 | Header 3 |
+|----------|----------|----------|
+| Cell 1 | Cell 2 | Cell 3 |
+| Cell 4 | Cell 5 | Cell 6 |
+`;
+
+ // Add newline before if not at start of line
+ const textBefore = this.noteContent.substring(0, cursorPos);
+ const needsNewlineBefore = textBefore.length > 0 && !textBefore.endsWith('\n');
+ const prefix = needsNewlineBefore ? '\n\n' : '';
+
+ // Insert the table
+ this.noteContent = textBefore + prefix + table + this.noteContent.substring(cursorPos);
+
+ // Position cursor at first header for easy editing
+ this.$nextTick(() => {
+ const newPos = cursorPos + prefix.length + 2; // After "| "
+ editor.setSelectionRange(newPos, newPos + 8); // Select "Header 1"
+ editor.focus();
+ });
+
+ // Trigger autosave
+ this.autoSave();
+ },
+
+ // Format selected text or insert formatting at cursor
+ formatText(type) {
+ // Simple wrap cases - reuse wrapSelection()
+ const wrapFormats = {
+ 'bold': ['**', '**', 'bold'],
+ 'italic': ['*', '*', 'italic'],
+ 'strikethrough': ['~~', '~~', 'strikethrough'],
+ 'code': ['`', '`', 'code']
+ };
+
+ if (wrapFormats[type]) {
+ const [before, after, placeholder] = wrapFormats[type];
+ this.wrapSelection(before, after, placeholder);
+ return;
+ }
+
+ // Special cases that need custom handling
+ switch (type) {
+ case 'heading':
+ this.insertLinePrefix('## ', 'Heading');
+ break;
+ case 'quote':
+ this.insertLinePrefix('> ', 'quote');
+ break;
+ case 'bullet':
+ this.insertLinePrefix('- ', 'item');
+ break;
+ case 'numbered':
+ this.insertLinePrefix('1. ', 'item');
+ break;
+ case 'checkbox':
+ this.insertLinePrefix('- [ ] ', 'task');
+ break;
+ case 'link':
+ this.insertLink();
+ break;
+ case 'image':
+ this.wrapSelection('', 'alt text');
+ break;
+ case 'codeblock':
+ this.wrapSelection('```\n', '\n```', 'code');
+ break;
+ case 'table':
+ this.insertTable();
+ break;
+ }
+ },
+
+ // Insert a line prefix (for headings, lists, quotes)
+ insertLinePrefix(prefix, placeholder) {
+ const editor = document.getElementById('note-editor');
+ if (!editor) return;
+
+ const start = editor.selectionStart;
+ const end = editor.selectionEnd;
+ const selectedText = this.noteContent.substring(start, end);
+ const beforeText = this.noteContent.substring(0, start);
+ const afterText = this.noteContent.substring(end);
+
+ // Check if at start of line
+ const atLineStart = beforeText.endsWith('\n') || beforeText === '';
+ const newline = atLineStart ? '' : '\n';
+
+ let replacement;
+ if (selectedText) {
+ // Prefix each line of selection
+ replacement = newline + selectedText.split('\n').map((line, i) => {
+ // For numbered lists, increment the number
+ if (prefix === '1. ') return `${i + 1}. ${line}`;
+ return prefix + line;
+ }).join('\n');
+ } else {
+ replacement = newline + prefix + placeholder;
+ }
+
+ this.noteContent = beforeText + replacement + afterText;
+
+ this.$nextTick(() => {
+ if (selectedText) {
+ editor.setSelectionRange(start + newline.length, start + replacement.length);
+ } else {
+ const placeholderStart = start + newline.length + prefix.length;
+ editor.setSelectionRange(placeholderStart, placeholderStart + placeholder.length);
+ }
+ editor.focus();
+ });
+
+ this.autoSave();
+ },
+
+ // Save current note
+ async saveNote() {
+ if (!this.currentNote) return;
+
+ this.isSaving = true;
+
+ try {
+ const response = await fetch(`/api/notes/${this.currentNote}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ content: this.noteContent })
+ });
+
+ if (response.ok) {
+ this.lastSaved = true;
+
+ // Update only the modified timestamp for the current note (no full reload needed)
+ const note = this.notes.find(n => n.path === this.currentNote);
+ if (note) {
+ note.modified = new Date().toISOString();
+ note.size = new Blob([this.noteContent]).size;
+
+ // Parse tags from content
+ note.tags = this.parseTagsFromContent(this.noteContent);
+ }
+
+ // Reload tags to update sidebar counts (debounced to prevent spam)
+ this.loadTagsDebounced();
+
+ // Rebuild folder tree if tag filters are active
+ if (this.selectedTags.length > 0) {
+ this.buildFolderTree();
+ }
+
+ // Hide "saved" indicator
+ setTimeout(() => {
+ this.lastSaved = false;
+ }, CONFIG.SAVE_INDICATOR_DURATION);
+ } else {
+ const err = new Error('Server returned error');
+ err.status = response.status;
+ ErrorHandler.handle('save note', err);
+ }
+ } catch (error) {
+ ErrorHandler.handle('save note', error);
+ } finally {
+ this.isSaving = false;
+ }
+ },
+
+ // Rename current note
+ async renameNote() {
+ if (!this.currentNote) return;
+
+ const oldPath = this.currentNote;
+ const newName = this.currentNoteName.trim();
+
+ if (!newName) {
+ alert(this.t('notes.empty_name'));
+ return;
+ }
+
+ // Validate the new name (single segment, no path separators)
+ const validation = FilenameValidator.validateFilename(newName);
+ if (!validation.valid) {
+ alert(this.getValidationErrorMessage(validation, 'note'));
+ // Reset the name in the UI
+ this.currentNoteName = oldPath.split('/').pop().replace('.md', '');
+ return;
+ }
+
+ const validatedName = validation.sanitized;
+ const folder = oldPath.split('/').slice(0, -1).join('/');
+ const newPath = folder ? `${folder}/${validatedName}.md` : `${validatedName}.md`;
+
+ if (oldPath === newPath) return;
+
+ // Check if a note with the new name already exists
+ const existingNote = this.notes.find(n => n.path.toLowerCase() === newPath.toLowerCase());
+ if (existingNote) {
+ alert(this.t('notes.already_exists', { name: validatedName }));
+ // Reset the name in the UI
+ this.currentNoteName = oldPath.split('/').pop().replace('.md', '');
+ return;
+ }
+
+ // Create new note with same content
+ try {
+ const response = await fetch(`/api/notes/${newPath}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ content: this.noteContent })
+ });
+
+ if (response.ok) {
+ // Delete old note
+ await fetch(`/api/notes/${oldPath}`, { method: 'DELETE' });
+
+ // Update favorites if the renamed note was favorited
+ if (this.favoritesSet.has(oldPath)) {
+ const newFavorites = this.favorites.map(f => f === oldPath ? newPath : f);
+ this.favorites = newFavorites;
+ this.favoritesSet = new Set(newFavorites);
+ this.saveFavorites();
+ }
+
+ this.currentNote = newPath;
+ await this.loadNotes();
+ } else {
+ ErrorHandler.handle('rename note', new Error('Server returned error'));
+ }
+ } catch (error) {
+ ErrorHandler.handle('rename note', error);
+ }
+ },
+
+ // Delete current note
+ async deleteCurrentNote() {
+ if (!this.currentNote) return;
+
+ // Just call deleteNote with current note details
+ await this.deleteNote(this.currentNote, this.currentNoteName);
+ },
+
+ // Delete any note from sidebar
+ async deleteNote(notePath, noteName) {
+ if (!confirm(this.t('notes.confirm_delete', { name: noteName }))) return;
+
+ try {
+ const response = await fetch(`/api/notes/${notePath}`, {
+ method: 'DELETE'
+ });
+
+ if (response.ok) {
+ // Remove from favorites if it was favorited
+ if (this.favoritesSet.has(notePath)) {
+ const newFavorites = this.favorites.filter(f => f !== notePath);
+ this.favorites = newFavorites;
+ this.favoritesSet = new Set(newFavorites);
+ this.saveFavorites();
+ }
+
+ // If the deleted note is currently open, clear it
+ if (this.currentNote === notePath) {
+ this.currentNote = '';
+ this.noteContent = '';
+ this.currentNoteName = '';
+ this._lastRenderedContent = ''; // Clear render cache
+ this._cachedRenderedHTML = '';
+ document.title = this.appName;
+ // Redirect to root
+ window.history.replaceState({}, '', '/');
+ }
+
+ await this.loadNotes();
+ } else {
+ ErrorHandler.handle('delete note', new Error('Server returned error'));
+ }
+ } catch (error) {
+ ErrorHandler.handle('delete note', error);
+ }
+ },
+
+ // Search notes
+ debouncedSearchNotes() {
+ if (this.searchDebounceTimeout) {
+ clearTimeout(this.searchDebounceTimeout);
+ }
+
+ const hasTextSearch = this.searchQuery.trim().length > 0;
+ if (!hasTextSearch) {
+ this.isSearching = false;
+ this.searchNotes();
+ return;
+ }
+
+ this.isSearching = true;
+ this.searchResults = [];
+
+ this.searchDebounceTimeout = setTimeout(() => {
+ this.searchNotes();
+ }, CONFIG.SEARCH_DEBOUNCE_DELAY);
+ },
+
+ // Search notes by text (calls unified filter logic)
+ async searchNotes() {
+ await this.applyFilters();
+ },
+
+ // Trigger MathJax typesetting after DOM update
+ typesetMath() {
+ if (typeof MathJax !== 'undefined' && MathJax.typesetPromise) {
+ // Use a small delay to ensure DOM is updated
+ setTimeout(() => {
+ const previewContent = document.querySelector('.markdown-preview');
+ if (previewContent) {
+ MathJax.typesetPromise([previewContent]).catch((err) => {
+ console.error('MathJax typesetting failed:', err);
+ });
+ }
+ }, 10);
+ }
+ },
+
+ // Render Mermaid diagrams
+ async renderMermaid() {
+ if (typeof window.mermaid === 'undefined') {
+ console.warn('Mermaid not loaded yet');
+ return;
+ }
+
+ // Use requestAnimationFrame for better performance than setTimeout
+ requestAnimationFrame(async () => {
+ const previewContent = document.querySelector('.markdown-preview');
+ if (!previewContent) return;
+
+ // Get the appropriate theme based on current app theme
+ const themeType = this.getThemeType();
+ const mermaidTheme = themeType === 'light' ? 'default' : 'dark';
+
+ // Only reinitialize if theme changed (performance optimization)
+ if (this.lastMermaidTheme !== mermaidTheme) {
+ window.mermaid.initialize({
+ startOnLoad: false,
+ theme: mermaidTheme,
+ securityLevel: 'strict', // Use strict for better security
+ fontFamily: 'inherit',
+ // v11 changed useMaxWidth defaults - restore responsive behavior
+ flowchart: { useMaxWidth: true },
+ sequence: { useMaxWidth: true },
+ gantt: { useMaxWidth: true },
+ journey: { useMaxWidth: true },
+ timeline: { useMaxWidth: true },
+ class: { useMaxWidth: true },
+ state: { useMaxWidth: true },
+ er: { useMaxWidth: true },
+ pie: { useMaxWidth: true },
+ quadrantChart: { useMaxWidth: true },
+ requirement: { useMaxWidth: true },
+ mindmap: { useMaxWidth: true },
+ gitGraph: { useMaxWidth: true }
+ });
+ this.lastMermaidTheme = mermaidTheme;
+ }
+
+ // Find all code blocks with language 'mermaid'
+ const mermaidBlocks = previewContent.querySelectorAll('pre code.language-mermaid');
+
+ // Early return if no diagrams to render
+ if (mermaidBlocks.length === 0) return;
+
+ for (let i = 0; i < mermaidBlocks.length; i++) {
+ const block = mermaidBlocks[i];
+ const pre = block.parentElement;
+
+ // Skip if already rendered (performance optimization)
+ if (pre.querySelector('.mermaid-rendered')) continue;
+
+ try {
+ const code = block.textContent;
+ const id = `mermaid-diagram-${Date.now()}-${i}`;
+
+ // Render the diagram
+ const { svg } = await window.mermaid.render(id, code);
+
+ // Create a container for the rendered diagram
+ const container = document.createElement('div');
+ container.className = 'mermaid-rendered';
+ container.style.cssText = 'background-color: transparent; padding: 20px; text-align: center; overflow-x: auto;';
+ container.innerHTML = svg;
+ // Store original code for theme re-rendering
+ container.dataset.originalCode = code;
+
+ // Replace the code block with the rendered diagram
+ pre.parentElement.replaceChild(container, pre);
+ } catch (error) {
+ console.error('Mermaid rendering error:', error);
+ // Add error indicator to the code block
+ const errorMsg = document.createElement('div');
+ errorMsg.style.cssText = 'color: var(--error); padding: 10px; border-left: 3px solid var(--error); margin-top: 10px;';
+ errorMsg.textContent = `โ ๏ธ Mermaid diagram error: ${error.message}`;
+ pre.parentElement.insertBefore(errorMsg, pre.nextSibling);
+ }
+ }
+ });
+ },
+
+ // Get current theme type (light or dark)
+ // Returns: 'light' or 'dark'
+ // Used by features that need to adapt to theme brightness (e.g., Mermaid diagrams, Chart.js)
+ getThemeType() {
+ // Handle system theme
+ if (this.currentTheme === 'system') {
+ const isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
+ return isDark ? 'dark' : 'light';
+ }
+
+ // Try to get theme type from loaded theme metadata
+ const currentThemeData = this.availableThemes.find(t => t.id === this.currentTheme);
+ if (currentThemeData && currentThemeData.type) {
+ // Use metadata from theme file (light or dark)
+ return currentThemeData.type; // Already 'light' or 'dark'
+ }
+
+ // Backward compatibility: fallback to hardcoded map if metadata not available
+ const fallbackMap = {
+ 'light': 'light',
+ 'vs-blue': 'light'
+ };
+
+ return fallbackMap[this.currentTheme] || 'dark';
+ },
+
+
+ // Computed property for rendered markdown
+ get renderedMarkdown() {
+ if (!this.noteContent) return 'Nothing to preview yet...
';
+
+ // Performance: Return cached HTML if content hasn't changed
+ if (this.noteContent === this._lastRenderedContent && this._cachedRenderedHTML) {
+ return this._cachedRenderedHTML;
+ }
+
+ // Strip YAML frontmatter from content before rendering
+ let contentToRender = this.noteContent;
+ if (contentToRender.trim().startsWith('---')) {
+ const lines = contentToRender.split('\n');
+ if (lines[0].trim() === '---') {
+ // Find closing ---
+ let endIdx = -1;
+ for (let i = 1; i < lines.length; i++) {
+ if (lines[i].trim() === '---') {
+ endIdx = i;
+ break;
+ }
+ }
+ if (endIdx !== -1) {
+ // Remove frontmatter (including the closing ---) and any empty lines after it
+ contentToRender = lines.slice(endIdx + 1).join('\n').trim();
+ }
+ }
+ }
+
+ // Convert Obsidian-style wikilinks: [[note]] or [[note|display text]]
+ // Must be done before marked.parse() to avoid conflicts with markdown syntax
+ // BUT we need to protect code blocks first to avoid converting [[text]] inside code
+ const self = this; // Reference for closure
+
+ // Step 1: Temporarily replace code blocks and inline code with placeholders
+ const codeBlocks = [];
+ // Protect fenced code blocks (```...```)
+ contentToRender = contentToRender.replace(/```[\s\S]*?```/g, (match) => {
+ codeBlocks.push(match);
+ return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`;
+ });
+ // Protect inline code (`...`)
+ contentToRender = contentToRender.replace(/`[^`]+`/g, (match) => {
+ codeBlocks.push(match);
+ return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`;
+ });
+
+ // Step 2: Convert media wikilinks FIRST: ![[file.png]] or ![[file.png|alt text]]
+ // Must be before note wikilinks to prevent [[file.png]] from being matched first
+ contentToRender = contentToRender.replace(
+ /!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
+ (match, mediaName, altText) => {
+ const filename = mediaName.trim();
+ const alt = altText ? altText.trim() : filename.replace(/\.[^/.]+$/, '');
+
+ // Resolve media path using O(1) lookup
+ const mediaPath = self.resolveMediaWikilink(filename);
+
+ if (mediaPath) {
+ // URL-encode path segments for the API
+ const encodedPath = mediaPath.split('/').map(segment => {
+ try {
+ return encodeURIComponent(decodeURIComponent(segment));
+ } catch (e) {
+ return encodeURIComponent(segment);
+ }
+ }).join('/');
+
+ const safeAlt = alt.replace(/"/g, '"');
+ const mediaSrc = `/api/media/${encodedPath}`;
+ const mediaType = self.getMediaType(filename);
+
+ // Return appropriate HTML based on media type
+ switch (mediaType) {
+ case 'audio':
+ return ``;
+ case 'video':
+ return ``;
+ case 'document':
+ // Local PDFs: show iframe preview
+ return ``;
+ default: // image
+ return `
`;
+ }
+ }
+
+ // Media not found - return broken indicator
+ const safeFilename = filename.replace(/&/g, '&').replace(//g, '>');
+ const mediaType = self.getMediaType(filename);
+ const icon = mediaType === 'audio' ? '๐ต' : mediaType === 'video' ? '๐ฌ' : mediaType === 'document' ? '๐' : '๐ผ๏ธ';
+ return `${icon} ${safeFilename}`;
+ }
+ );
+
+ // Step 2b: Convert note wikilinks: [[note]] or [[note|display text]]
+ contentToRender = contentToRender.replace(
+ /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
+ (match, target, displayText) => {
+ const linkTarget = target.trim();
+ const linkText = displayText ? displayText.trim() : linkTarget;
+
+ // Fast O(1) check using pre-built lookup maps
+ // Handle section anchors: extract base note path
+ const hashIndex = linkTarget.indexOf('#');
+ const basePath = hashIndex !== -1 ? linkTarget.substring(0, hashIndex) : linkTarget;
+ const noteExists = basePath === '' || self.wikiLinkExists(basePath);
+
+ // Escape special chars: href needs quote escaping, text needs HTML escaping
+ const safeHref = linkTarget.replace(/"/g, '%22');
+ const safeText = linkText.replace(/&/g, '&').replace(//g, '>');
+
+ // Return link with data attribute for styling broken links
+ const brokenClass = noteExists ? '' : ' class="wikilink-broken"';
+ return `${safeText}`;
+ }
+ );
+
+ // Step 3: Restore code blocks
+ contentToRender = contentToRender.replace(/\x00CODEBLOCK(\d+)\x00/g, (match, index) => {
+ return codeBlocks[parseInt(index)];
+ });
+
+ // Protect LaTeX \(...\) and \[...\] delimiters from marked.js escaping
+ marked.use({
+ extensions: [{
+ name: 'protectLatexMath',
+ level: 'inline',
+ start(src) { return src.match(/\\[\(\[]/)?.index; },
+ tokenizer(src) {
+ // Match \(...\) or \[...\]
+ const match = src.match(/^(\\[\(\[])([\s\S]*?)(\\[\)\]])/);
+ if (match) {
+ return {
+ type: 'html',
+ raw: match[0],
+ text: match[0]
+ };
+ }
+ }
+ }]
+ });
+
+ // Configure marked with syntax highlighting
+ marked.setOptions({
+ breaks: true,
+ gfm: true,
+ highlight: function(code, lang) {
+ if (lang && hljs.getLanguage(lang)) {
+ try {
+ return hljs.highlight(code, { language: lang }).value;
+ } catch (err) {
+ console.error('Highlight error:', err);
+ }
+ }
+ return hljs.highlightAuto(code).value;
+ }
+ });
+
+ // Parse markdown
+ let html = marked.parse(contentToRender);
+
+ // Sanitize HTML to prevent XSS attacks
+ // DOMPurify defaults allow most HTML/SVG tags but strip scripts, iframes, and event handlers
+ // MathJax and Mermaid run AFTER this, so their elements don't need whitelisting
+ html = DOMPurify.sanitize(html);
+
+ // Post-process: Add target="_blank" to external links and title attributes to images
+ // Parse as DOM to safely manipulate
+ const tempDiv = document.createElement('div');
+ tempDiv.innerHTML = html;
+
+ // Find all links
+ const links = tempDiv.querySelectorAll('a');
+ links.forEach(link => {
+ const href = link.getAttribute('href');
+ if (href && typeof href === 'string') {
+ // Check if it's an external link
+ const isExternal = href.indexOf('http://') === 0 ||
+ href.indexOf('https://') === 0 ||
+ href.indexOf('//') === 0;
+
+ if (isExternal) {
+ link.setAttribute('target', '_blank');
+ link.setAttribute('rel', 'noopener noreferrer');
+ }
+ }
+ });
+
+ // Find all images and transform paths for display
+ // Also convert non-image media (audio, video, PDF) to appropriate elements
+ const images = tempDiv.querySelectorAll('img');
+ images.forEach(img => {
+ let src = img.getAttribute('src');
+ if (src) {
+ const isExternal = src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//');
+ const isLocal = !isExternal && !src.startsWith('data:');
+
+ // Transform relative paths to /api/media/ for serving
+ if (isLocal && !src.startsWith('/api/media/')) {
+ // URL-encode path segments to handle spaces and special characters
+ const encodedPath = src.split('/').map(segment => {
+ try {
+ return encodeURIComponent(decodeURIComponent(segment));
+ } catch (e) {
+ return encodeURIComponent(segment);
+ }
+ }).join('/');
+ src = `/api/media/${encodedPath}`;
+ img.setAttribute('src', src);
+ }
+
+ // Check if this is non-image media and convert to appropriate element
+ const mediaType = self.getMediaType(src);
+ const altText = img.getAttribute('alt') || src.split('/').pop().replace(/\.[^/.]+$/, '');
+ const safeAlt = altText.replace(/"/g, '"');
+
+ // Only convert LOCAL media to embedded elements (security)
+ // External non-image media gets styled links instead
+ if (isLocal || src.startsWith('/api/media/')) {
+ if (mediaType === 'audio') {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'media-embed media-audio';
+ wrapper.innerHTML = `${safeAlt}`;
+ img.replaceWith(wrapper);
+ return;
+ } else if (mediaType === 'video') {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'media-embed media-video';
+ wrapper.innerHTML = ``;
+ img.replaceWith(wrapper);
+ return;
+ } else if (mediaType === 'document') {
+ // Local PDFs: show iframe preview
+ const wrapper = document.createElement('div');
+ wrapper.className = 'media-embed media-pdf';
+ wrapper.innerHTML = ``;
+ img.replaceWith(wrapper);
+ return;
+ }
+ } else if (isExternal && mediaType === 'document') {
+ // External PDFs: styled link (opens in new tab)
+ const link = document.createElement('a');
+ link.href = src;
+ link.target = '_blank';
+ link.rel = 'noopener noreferrer';
+ link.className = 'pdf-link';
+ link.title = `Open ${safeAlt}`;
+ link.innerHTML = `๐ ${safeAlt}Opens in new tab`;
+ img.replaceWith(link);
+ return;
+ }
+ // External audio/video: leave as broken image for security
+ }
+
+ // For regular images, set title attribute
+ const altText = img.getAttribute('alt');
+ if (altText) {
+ img.setAttribute('title', altText);
+ }
+ });
+
+ html = tempDiv.innerHTML;
+
+ // Debounced MathJax rendering (avoid re-running on every keystroke)
+ if (this._mathDebounceTimeout) clearTimeout(this._mathDebounceTimeout);
+ this._mathDebounceTimeout = setTimeout(() => this.typesetMath(), 300);
+
+ // Debounced Mermaid rendering
+ if (this._mermaidDebounceTimeout) clearTimeout(this._mermaidDebounceTimeout);
+ this._mermaidDebounceTimeout = setTimeout(() => this.renderMermaid(), 300);
+
+ // Apply syntax highlighting and add copy buttons to code blocks
+ setTimeout(() => {
+ // Use cached reference if available, otherwise query
+ const previewEl = this._domCache.previewContent || document.querySelector('.markdown-preview');
+ if (previewEl) {
+ // Exclude code blocks that are rendered by other tools (e.g., Mermaid diagrams)
+ // Note: MathJax uses $$...$$ delimiters (not code blocks) so no exclusion needed
+ previewEl.querySelectorAll('pre code:not(.language-mermaid)').forEach((block) => {
+ // Apply syntax highlighting
+ if (!block.classList.contains('hljs')) {
+ hljs.highlightElement(block);
+ }
+
+ // Add copy button if not already present
+ const pre = block.parentElement;
+ if (pre && !pre.querySelector('.copy-code-button')) {
+ this.addCopyButtonToCodeBlock(pre);
+ }
+ });
+
+ // Enable video metadata loading (for first frame preview)
+ // Track by source URL to prevent duplicate requests on re-renders
+ if (!this._initializedVideoSources) this._initializedVideoSources = new Set();
+ previewEl.querySelectorAll('video[preload="none"]').forEach((video) => {
+ const src = video.getAttribute('src');
+ if (src && !this._initializedVideoSources.has(src)) {
+ this._initializedVideoSources.add(src);
+ video.preload = 'metadata';
+ }
+ });
+ }
+ }, 0);
+
+ // Cache the result for performance
+ this._lastRenderedContent = this.noteContent;
+ this._cachedRenderedHTML = html;
+
+ return html;
+ },
+
+ // Refresh DOM element cache
+ refreshDOMCache() {
+ this._domCache.editor = document.querySelector('.editor-textarea');
+ this._domCache.previewContent = document.querySelector('.markdown-preview');
+ this._domCache.previewContainer = this._domCache.previewContent ? this._domCache.previewContent.parentElement : null;
+ },
+
+ // Add copy button to code block
+ addCopyButtonToCodeBlock(preElement) {
+ // Extract language from code element class (e.g., "language-toml" -> "TOML")
+ const codeElement = preElement.querySelector('code');
+ let language = '';
+ if (codeElement && codeElement.className) {
+ const match = codeElement.className.match(/language-(\w+)/);
+ if (match) {
+ const langMap = {
+ 'js': 'JavaScript', 'ts': 'TypeScript', 'py': 'Python',
+ 'rb': 'Ruby', 'cs': 'C#', 'cpp': 'C++', 'sh': 'Shell',
+ 'bash': 'Bash', 'zsh': 'Zsh', 'yml': 'YAML', 'md': 'Markdown'
+ };
+ const rawLang = match[1].toLowerCase();
+ language = langMap[rawLang] || match[1].toUpperCase();
+ }
+ }
+
+ // Create copy button with language label
+ const button = document.createElement('button');
+ button.className = 'copy-code-button';
+ const displayText = language || this.t('common.copy_to_clipboard').split(' ')[0]; // Use first word as fallback
+ button.innerHTML = `${displayText}`;
+ button.dataset.originalText = displayText; // Store for restore after copy
+ button.title = this.t('common.copy_to_clipboard');
+
+ // Style the button
+ button.style.position = 'absolute';
+ button.style.top = '8px';
+ button.style.right = '8px';
+ button.style.padding = '4px 10px';
+ button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
+ button.style.border = 'none';
+ button.style.borderRadius = '4px';
+ button.style.cursor = 'pointer';
+ button.style.opacity = '0';
+ button.style.transition = 'opacity 0.2s, background-color 0.2s';
+ button.style.color = 'white';
+ button.style.display = 'flex';
+ button.style.alignItems = 'center';
+ button.style.justifyContent = 'center';
+ button.style.zIndex = '10';
+ button.style.fontSize = '11px';
+ button.style.fontWeight = '600';
+ button.style.fontFamily = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace';
+ button.style.textTransform = 'uppercase';
+ button.style.letterSpacing = '0.5px';
+
+ // Style the pre element to be relative
+ preElement.style.position = 'relative';
+
+ // Show button on hover
+ preElement.addEventListener('mouseenter', () => {
+ button.style.opacity = '1';
+ });
+
+ preElement.addEventListener('mouseleave', () => {
+ button.style.opacity = '0';
+ });
+
+ // Copy to clipboard on click
+ button.addEventListener('click', async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const codeElement = preElement.querySelector('code');
+ if (!codeElement) return;
+
+ const code = codeElement.textContent;
+
+ const originalText = button.dataset.originalText;
+ const copiedText = this.t('common.copied');
+ const copyTitle = this.t('common.copy_to_clipboard');
+
+ try {
+ await navigator.clipboard.writeText(code);
+
+ // Visual feedback - show localized "Copied!"
+ button.innerHTML = `${copiedText}`;
+ button.style.backgroundColor = 'rgba(34, 197, 94, 0.8)';
+ button.title = copiedText;
+
+ // Reset after 2 seconds
+ setTimeout(() => {
+ button.innerHTML = `${originalText}`;
+ button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
+ button.title = copyTitle;
+ }, 2000);
+ } catch (err) {
+ console.error('Failed to copy code:', err);
+
+ // Fallback for older browsers
+ const textArea = document.createElement('textarea');
+ textArea.value = code;
+ textArea.style.position = 'fixed';
+ textArea.style.left = '-999999px';
+ document.body.appendChild(textArea);
+ textArea.select();
+
+ try {
+ document.execCommand('copy');
+ button.innerHTML = `${copiedText}`;
+ button.style.backgroundColor = 'rgba(34, 197, 94, 0.8)';
+ setTimeout(() => {
+ button.innerHTML = `${originalText}`;
+ button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
+ }, 2000);
+ } catch (fallbackErr) {
+ console.error('Fallback copy failed:', fallbackErr);
+ }
+
+ document.body.removeChild(textArea);
+ }
+ });
+
+ // Add button to pre element
+ preElement.appendChild(button);
+ },
+
+ // Setup scroll synchronization
+ setupScrollSync() {
+ // Use cached references (refresh if not available)
+ if (!this._domCache.editor || !this._domCache.previewContainer) {
+ this.refreshDOMCache();
+ }
+
+ const editor = this._domCache.editor;
+ const preview = this._domCache.previewContainer;
+
+ if (!editor || !preview) {
+ // If elements don't exist yet, retry with limit
+ if (!this._setupScrollSyncRetries) this._setupScrollSyncRetries = 0;
+ if (this._setupScrollSyncRetries < CONFIG.SCROLL_SYNC_MAX_RETRIES) {
+ this._setupScrollSyncRetries++;
+ setTimeout(() => this.setupScrollSync(), CONFIG.SCROLL_SYNC_RETRY_INTERVAL);
+ } else {
+ console.warn(`setupScrollSync: Failed to find editor/preview elements after ${CONFIG.SCROLL_SYNC_MAX_RETRIES} retries`);
+ }
+ return;
+ }
+
+ // Reset retry counter on success
+ this._setupScrollSyncRetries = 0;
+
+ // Remove old listeners if they exist
+ if (this._editorScrollHandler) {
+ editor.removeEventListener('scroll', this._editorScrollHandler);
+ }
+ if (this._previewScrollHandler) {
+ preview.removeEventListener('scroll', this._previewScrollHandler);
+ }
+
+ // Create new scroll handlers
+ this._editorScrollHandler = () => {
+ if (this.isScrolling) {
+ this.isScrolling = false;
+ return;
+ }
+
+ const scrollableHeight = editor.scrollHeight - editor.clientHeight;
+ if (scrollableHeight <= 0) return; // No scrolling needed
+
+ const scrollPercentage = editor.scrollTop / scrollableHeight;
+ const previewScrollableHeight = preview.scrollHeight - preview.clientHeight;
+
+ if (previewScrollableHeight > 0) {
+ this.isScrolling = true;
+ preview.scrollTop = scrollPercentage * previewScrollableHeight;
+ }
+ };
+
+ this._previewScrollHandler = () => {
+ if (this.isScrolling) {
+ this.isScrolling = false;
+ return;
+ }
+
+ const scrollableHeight = preview.scrollHeight - preview.clientHeight;
+ if (scrollableHeight <= 0) return; // No scrolling needed
+
+ const scrollPercentage = preview.scrollTop / scrollableHeight;
+ const editorScrollableHeight = editor.scrollHeight - editor.clientHeight;
+
+ if (editorScrollableHeight > 0) {
+ this.isScrolling = true;
+ editor.scrollTop = scrollPercentage * editorScrollableHeight;
+ }
+ };
+
+ // Attach new listeners
+ editor.addEventListener('scroll', this._editorScrollHandler);
+ preview.addEventListener('scroll', this._previewScrollHandler);
+ },
+
+ // Check if stats plugin is enabled
+ async checkStatsPlugin() {
+ try {
+ const response = await fetch('/api/plugins');
+ const data = await response.json();
+ const statsPlugin = data.plugins.find(p => p.id === 'note_stats');
+ this.statsPluginEnabled = statsPlugin && statsPlugin.enabled;
+
+ // Calculate stats for current note if enabled
+ if (this.statsPluginEnabled && this.noteContent) {
+ this.calculateStats();
+ }
+ } catch (error) {
+ console.error('Failed to check stats plugin:', error);
+ this.statsPluginEnabled = false;
+ }
+ },
+
+ // Calculate note statistics (client-side)
+ calculateStats() {
+ if (!this.statsPluginEnabled || !this.noteContent) {
+ this.noteStats = null;
+ return;
+ }
+
+ const content = this.noteContent;
+
+ // Word count
+ const words = (content.match(/\S+/g) || []).length;
+
+ // Character count
+ const chars = content.replace(/\s/g, '').length;
+ const totalChars = content.length;
+
+ // Reading time (200 words per minute)
+ const readingTime = Math.max(1, Math.round(words / 200));
+
+ // Line count
+ const lines = content.split('\n').length;
+
+ // Paragraph count
+ const paragraphs = content.split('\n\n').filter(p => p.trim()).length;
+
+ // Sentences: punctuation [.!?]+ followed by space or end-of-string
+ const sentences = (content.match(/[.!?]+(?:\s|$)/g) || []).length;
+
+ // List items: lines starting with -, *, + or a number (e.g. 1., 10.), excluding tasks [-]
+ const listItems = (content.match(/^\s*(?:[-*+]|\d+\.)\s+(?!\[)/gm) || []).length;
+
+ // Tables: markdown table separator rows (| --- | --- |)
+ const tables = (content.match(/^\s*\|(?:\s*:?-+:?\s*\|){1,}\s*$/gm) || []).length;
+
+ // Link count (standard markdown links)
+ const markdownLinkMatches = content.match(/\[([^\]]+)\]\(([^\)]+)\)/g) || [];
+ const markdownLinks = markdownLinkMatches.length;
+ const markdownInternalLinks = markdownLinkMatches.filter(l => l.includes('.md')).length;
+
+ // Wikilink count ([[note]] or [[note|display text]] format)
+ const wikilinks = (content.match(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g) || []).length;
+
+ // Total links (markdown + wikilinks)
+ const links = markdownLinks + wikilinks;
+ const internalLinks = markdownInternalLinks + wikilinks; // All wikilinks are internal
+
+ // Code blocks
+ const codeBlocks = (content.match(/```[\s\S]*?```/g) || []).length;
+ const inlineCode = (content.match(/`[^`]+`/g) || []).length;
+
+ // Headings
+ const h1 = (content.match(/^# /gm) || []).length;
+ const h2 = (content.match(/^## /gm) || []).length;
+ const h3 = (content.match(/^### /gm) || []).length;
+
+ // Tasks
+ const totalTasks = (content.match(/- \[[ x]\]/gi) || []).length;
+ const completedTasks = (content.match(/- \[x\]/gi) || []).length;
+ const pendingTasks = totalTasks - completedTasks;
+ const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
+
+ // Images
+ const images = (content.match(/!\[([^\]]*)\]\(([^\)]+)\)/g) || []).length;
+
+ // Blockquotes
+ const blockquotes = (content.match(/^> /gm) || []).length;
+
+ this.noteStats = {
+ words,
+ sentences,
+ characters: chars,
+ total_characters: totalChars,
+ reading_time_minutes: readingTime,
+ lines,
+ paragraphs,
+ list_items: listItems,
+ tables,
+ links,
+ internal_links: internalLinks,
+ external_links: links - internalLinks,
+ wikilinks,
+ code_blocks: codeBlocks,
+ inline_code: inlineCode,
+ headings: {
+ h1,
+ h2,
+ h3,
+ total: h1 + h2 + h3
+ },
+ tasks: {
+ total: totalTasks,
+ completed: completedTasks,
+ pending: pendingTasks,
+ completion_rate: completionRate
+ },
+ images,
+ blockquotes
+ };
+ },
+
+ // Parse YAML frontmatter metadata from note content
+ parseMetadata() {
+ if (!this.noteContent) {
+ this.noteMetadata = null;
+ this._lastFrontmatter = null;
+ return;
+ }
+
+ const content = this.noteContent;
+
+ // Check if content starts with frontmatter
+ if (!content.trim().startsWith('---')) {
+ this.noteMetadata = null;
+ this._lastFrontmatter = null;
+ return;
+ }
+
+ try {
+ const lines = content.split('\n');
+ if (lines[0].trim() !== '---') {
+ this.noteMetadata = null;
+ this._lastFrontmatter = null;
+ return;
+ }
+
+ // Find closing ---
+ let endIdx = -1;
+ for (let i = 1; i < lines.length; i++) {
+ if (lines[i].trim() === '---') {
+ endIdx = i;
+ break;
+ }
+ }
+
+ if (endIdx === -1) {
+ this.noteMetadata = null;
+ this._lastFrontmatter = null;
+ return;
+ }
+
+ // Performance optimization: skip parsing if frontmatter unchanged
+ const frontmatterRaw = lines.slice(0, endIdx + 1).join('\n');
+ if (frontmatterRaw === this._lastFrontmatter) {
+ return; // No change, keep existing metadata
+ }
+ this._lastFrontmatter = frontmatterRaw;
+
+ const frontmatterLines = lines.slice(1, endIdx);
+ const metadata = {};
+ let currentKey = null;
+ let currentValue = [];
+
+ for (const line of frontmatterLines) {
+ // Check for new key: value pair (supports keys with hyphens/underscores)
+ const keyMatch = line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/);
+
+ if (keyMatch) {
+ // Save previous key if exists
+ if (currentKey) {
+ metadata[currentKey] = this.parseYamlValue(currentValue.join('\n'));
+ }
+
+ currentKey = keyMatch[1];
+ const value = keyMatch[2].trim();
+ currentValue = [value];
+ } else if (line.match(/^\s+-\s+/) && currentKey) {
+ // List item continuation (e.g., " - item")
+ currentValue.push(line);
+ } else if (line.startsWith(' ') && currentKey) {
+ // Indented content (multiline value)
+ currentValue.push(line);
+ }
+ }
+
+ // Save last key
+ if (currentKey) {
+ metadata[currentKey] = this.parseYamlValue(currentValue.join('\n'));
+ }
+
+ this.noteMetadata = Object.keys(metadata).length > 0 ? metadata : null;
+
+ } catch (error) {
+ console.error('Failed to parse frontmatter:', error);
+ this.noteMetadata = null;
+ this._lastFrontmatter = null;
+ }
+ },
+
+ // Parse a YAML value (handles arrays, strings, numbers, booleans)
+ parseYamlValue(value) {
+ if (!value || value.trim() === '') return null;
+
+ value = value.trim();
+
+ // Check for inline array: [item1, item2]
+ if (value.startsWith('[') && value.endsWith(']')) {
+ const inner = value.slice(1, -1);
+ return inner.split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(s => s);
+ }
+
+ // Check for YAML list format (multiple lines starting with -)
+ if (value.includes('\n -') || value.startsWith(' -')) {
+ const items = [];
+ const lines = value.split('\n');
+ for (const line of lines) {
+ const match = line.match(/^\s*-\s*(.+)$/);
+ if (match) {
+ items.push(match[1].trim().replace(/^["']|["']$/g, ''));
+ }
+ }
+ return items.length > 0 ? items : value;
+ }
+
+ // Check for boolean
+ if (value.toLowerCase() === 'true') return true;
+ if (value.toLowerCase() === 'false') return false;
+
+ // Check for number
+ if (/^-?\d+(\.\d+)?$/.test(value)) {
+ return parseFloat(value);
+ }
+
+ // Return as string (remove quotes if present)
+ return value.replace(/^["']|["']$/g, '');
+ },
+
+ // Check if a string is a URL
+ isUrl(str) {
+ if (typeof str !== 'string') return false;
+ return /^https?:\/\/\S+$/i.test(str.trim());
+ },
+
+ // Escape HTML to prevent XSS
+ escapeHtml(str) {
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+ },
+
+ // Format metadata value for display
+ formatMetadataValue(key, value) {
+ if (value === null || value === undefined) return '';
+
+ // Arrays are handled separately in the template
+ if (Array.isArray(value)) return value;
+
+ // Format dates nicely
+ if (key === 'date' || key === 'created' || key === 'modified' || key === 'updated') {
+ let date;
+ // Parse date-only strings (YYYY-MM-DD) as local dates to avoid timezone issues
+ if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
+ const [year, month, day] = value.split('-').map(Number);
+ date = new Date(year, month - 1, day); // month is 0-indexed
+ } else {
+ date = new Date(value);
+ }
+ if (!isNaN(date.getTime())) {
+ return date.toLocaleDateString(this.currentLocale, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric'
+ });
+ }
+ }
+
+ // Booleans
+ if (typeof value === 'boolean') {
+ return value ? this.t('common.yes') : this.t('common.no');
+ }
+
+ return String(value);
+ },
+
+ // Format metadata value as HTML (for URL support)
+ formatMetadataValueHtml(key, value) {
+ const formatted = this.formatMetadataValue(key, value);
+
+ // Check if it's a URL
+ if (this.isUrl(formatted)) {
+ const escaped = this.escapeHtml(formatted);
+ // Truncate long URLs for display
+ const displayUrl = formatted.length > 40
+ ? formatted.substring(0, 37) + '...'
+ : formatted;
+ return `${this.escapeHtml(displayUrl)}`;
+ }
+
+ return this.escapeHtml(formatted);
+ },
+
+ // Get priority metadata fields (shown in collapsed view)
+ getPriorityMetadataFields() {
+ if (!this.noteMetadata) return [];
+
+ // Fields to show in collapsed view, in order of priority
+ const priority = ['date', 'created', 'author', 'status', 'priority', 'type', 'category'];
+ const fields = [];
+
+ for (const key of priority) {
+ if (this.noteMetadata[key] !== undefined && !Array.isArray(this.noteMetadata[key])) {
+ const formatted = this.formatMetadataValue(key, this.noteMetadata[key]);
+ const isUrl = this.isUrl(formatted);
+ fields.push({
+ key,
+ value: formatted,
+ valueHtml: isUrl ? this.formatMetadataValueHtml(key, this.noteMetadata[key]) : this.escapeHtml(formatted),
+ isUrl
+ });
+ }
+ }
+
+ return fields.slice(0, 3); // Show max 3 fields in collapsed view
+ },
+
+ // Get all metadata fields except tags (for expanded view)
+ getAllMetadataFields() {
+ if (!this.noteMetadata) return [];
+
+ return Object.entries(this.noteMetadata)
+ .filter(([key]) => key !== 'tags') // Tags shown separately
+ .map(([key, value]) => {
+ const isArray = Array.isArray(value);
+ const formatted = this.formatMetadataValue(key, value);
+ const isUrl = !isArray && this.isUrl(formatted);
+ return {
+ key,
+ value: formatted,
+ valueHtml: isUrl ? this.formatMetadataValueHtml(key, value) : this.escapeHtml(formatted),
+ isArray,
+ isUrl
+ };
+ });
+ },
+
+ // Check if note has any displayable metadata
+ getHasMetadata() {
+ const has = this.noteMetadata && Object.keys(this.noteMetadata).length > 0;
+ return has;
+ },
+
+ // Get tags from metadata
+ getMetadataTags() {
+ if (!this.noteMetadata || !this.noteMetadata.tags) return [];
+ return Array.isArray(this.noteMetadata.tags) ? this.noteMetadata.tags : [this.noteMetadata.tags];
+ },
+
+ // Save sidebar width to localStorage
+ saveSidebarWidth() {
+ localStorage.setItem('sidebarWidth', this.sidebarWidth.toString());
+ },
+
+ // Save view mode to localStorage
+ saveViewMode() {
+ try {
+ localStorage.setItem('viewMode', this.viewMode);
+ } catch (error) {
+ console.error('Error saving view mode:', error);
+ }
+ },
+
+ saveTagsExpanded() {
+ try {
+ localStorage.setItem('tagsExpanded', this.tagsExpanded.toString());
+ } catch (error) {
+ console.error('Error saving tags expanded state:', error);
+ }
+ },
+
+ // Start resizing sidebar
+ startResize(event) {
+ this.isResizing = true;
+ event.preventDefault();
+
+ const resize = (e) => {
+ if (!this.isResizing) return;
+
+ // Calculate new width based on mouse position
+ const newWidth = e.clientX;
+
+ // Clamp between min and max
+ if (newWidth >= 200 && newWidth <= 600) {
+ this.sidebarWidth = newWidth;
+ }
+ };
+
+ const stopResize = () => {
+ if (this.isResizing) {
+ this.isResizing = false;
+ this.saveSidebarWidth();
+ document.removeEventListener('mousemove', resize);
+ document.removeEventListener('mouseup', stopResize);
+ }
+ };
+
+ document.addEventListener('mousemove', resize);
+ document.addEventListener('mouseup', stopResize);
+ },
+
+ // Start resizing split panes (editor/preview)
+ startSplitResize(event) {
+ this.isResizingSplit = true;
+ event.preventDefault();
+
+ const container = event.target.parentElement;
+
+ const resize = (e) => {
+ if (!this.isResizingSplit) return;
+
+ const containerRect = container.getBoundingClientRect();
+ const mouseX = e.clientX - containerRect.left;
+ const percentage = (mouseX / containerRect.width) * 100;
+
+ // Clamp between 20% and 80%
+ if (percentage >= 20 && percentage <= 80) {
+ this.editorWidth = percentage;
+ }
+ };
+
+ const stopResize = () => {
+ if (this.isResizingSplit) {
+ this.isResizingSplit = false;
+ this.saveEditorWidth();
+ document.removeEventListener('mousemove', resize);
+ document.removeEventListener('mouseup', stopResize);
+ }
+ };
+
+ document.addEventListener('mousemove', resize);
+ document.addEventListener('mouseup', stopResize);
+ },
+
+ // Setup mobile view mode handler (auto-switch from split to edit on mobile)
+ setupMobileViewMode() {
+ const MOBILE_BREAKPOINT = 768; // Match CSS breakpoint
+ let previousWidth = window.innerWidth;
+
+ const handleResize = () => {
+ const currentWidth = window.innerWidth;
+ const wasMobile = previousWidth <= MOBILE_BREAKPOINT;
+ const isMobile = currentWidth <= MOBILE_BREAKPOINT;
+
+ // If switching from desktop to mobile and in split mode
+ if (!wasMobile && isMobile && this.viewMode === 'split') {
+ this.viewMode = 'edit';
+ }
+
+ previousWidth = currentWidth;
+ };
+
+ // Listen for window resize
+ window.addEventListener('resize', handleResize);
+
+ // Check initial state
+ if (window.innerWidth <= MOBILE_BREAKPOINT && this.viewMode === 'split') {
+ this.viewMode = 'edit';
+ }
+ },
+
+ // Save editor width to localStorage
+ saveEditorWidth() {
+ localStorage.setItem('editorWidth', this.editorWidth.toString());
+ },
+
+ // Scroll to top of editor and preview
+ scrollToTop() {
+ // Disable scroll sync temporarily to prevent interference
+ this.isScrolling = true;
+
+ // Use cached references (refresh if not available)
+ if (!this._domCache.editor || !this._domCache.previewContainer) {
+ this.refreshDOMCache();
+ }
+
+ // Only scroll the visible panes based on viewMode
+ if (this.viewMode === 'edit' || this.viewMode === 'split') {
+ if (this._domCache.editor) {
+ this._domCache.editor.scrollTop = 0;
+ }
+ }
+
+ if (this.viewMode === 'preview' || this.viewMode === 'split') {
+ // Scroll the preview container (parent of .markdown-preview)
+ if (this._domCache.previewContainer) {
+ this._domCache.previewContainer.scrollTop = 0;
+ }
+ }
+
+ // Re-enable scroll sync after a short delay
+ setTimeout(() => {
+ this.isScrolling = false;
+ }, CONFIG.SCROLL_SYNC_DELAY);
+ },
+
+ // Export current note as HTML via backend API
+ async exportToHTML() {
+ if (!this.currentNote || !this.noteContent) {
+ alert(this.t('notes.no_content'));
+ return;
+ }
+
+ try {
+ // Build API URL with current theme
+ const currentTheme = this.currentTheme || 'light';
+ const encodedPath = this.currentNote.split('/').map(s => encodeURIComponent(s)).join('/');
+ const url = `/api/export/${encodedPath}?theme=${encodeURIComponent(currentTheme)}`;
+
+ // Fetch the exported HTML from backend
+ const response = await fetch(url);
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: 'Export failed' }));
+ throw new Error(error.detail || 'Export failed');
+ }
+
+ // Get filename from Content-Disposition header or use note name
+ let filename = (this.currentNoteName || 'note') + '.html';
+ const contentDisposition = response.headers.get('Content-Disposition');
+ if (contentDisposition) {
+ const match = contentDisposition.match(/filename="([^"]+)"/);
+ if (match) {
+ filename = match[1];
+ }
+ }
+
+ // Download as blob
+ const blob = await response.blob();
+ const blobUrl = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = blobUrl;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+
+ // Cleanup
+ URL.revokeObjectURL(blobUrl);
+ document.body.removeChild(a);
+
+ } catch (error) {
+ console.error('HTML export failed:', error);
+ alert(this.t('export.failed', { error: error.message }));
+ }
+ },
+
+ // Open print preview in new window
+ printPreview() {
+ if (!this.currentNote || !this.noteContent) {
+ alert(this.t('notes.no_content'));
+ return;
+ }
+
+ // Build API URL with current theme and download=false for inline display
+ const currentTheme = this.currentTheme || 'light';
+ const encodedPath = this.currentNote.split('/').map(s => encodeURIComponent(s)).join('/');
+ const url = `/api/export/${encodedPath}?theme=${encodeURIComponent(currentTheme)}&download=false`;
+
+ // Open in new window/tab
+ window.open(url, '_blank');
+ },
+
+ // Copy current note link to clipboard
+ async copyNoteLink() {
+ if (!this.currentNote) return;
+
+ // Build the full URL
+ const pathWithoutExtension = this.currentNote.replace('.md', '');
+ const encodedPath = pathWithoutExtension.split('/').map(segment => encodeURIComponent(segment)).join('/');
+ const url = `${window.location.origin}/${encodedPath}`;
+
+ try {
+ await navigator.clipboard.writeText(url);
+ } catch (error) {
+ // Fallback for older browsers
+ const textArea = document.createElement('textarea');
+ textArea.value = url;
+ document.body.appendChild(textArea);
+ textArea.select();
+ document.execCommand('copy');
+ document.body.removeChild(textArea);
+ }
+
+ // Show brief "Copied!" feedback
+ this.linkCopied = true;
+ setTimeout(() => {
+ this.linkCopied = false;
+ }, 1500);
+ },
+
+ // ============================================================================
+ // Share Functions
+ // ============================================================================
+
+ // Load list of shared note paths (for visual indicators)
+ async loadSharedNotePaths() {
+ try {
+ const response = await fetch('/api/shared-notes');
+ if (response.ok) {
+ const data = await response.json();
+ this._sharedNotePaths = new Set(data.paths || []);
+ }
+ } catch (error) {
+ console.error('Failed to load shared note paths:', error);
+ this._sharedNotePaths = new Set();
+ }
+ },
+
+ // Check if a note is currently shared (O(1) lookup)
+ isNoteShared(notePath) {
+ return this._sharedNotePaths.has(notePath);
+ },
+
+ // ============================================
+ // Quick Switcher (Ctrl+Alt+P)
+ // ============================================
+
+ openQuickSwitcher() {
+ this.showQuickSwitcher = true;
+ this.quickSwitcherQuery = '';
+ this.quickSwitcherIndex = 0;
+ // Populate initial results
+ this.quickSwitcherResults = (this.allNotes || []).slice(0, 10);
+ // Focus the input after the modal renders
+ this.$nextTick(() => {
+ const input = document.getElementById('quickSwitcherInput');
+ if (input) input.focus();
+ });
+ },
+
+ closeQuickSwitcher() {
+ this.showQuickSwitcher = false;
+ this.quickSwitcherQuery = '';
+ this.quickSwitcherIndex = 0;
+ },
+
+ // Filter notes for quick switcher based on query
+ filterQuickSwitcher(query) {
+ // Only include actual notes, not images
+ const notes = (this.notes || []).filter(n => n.type === 'note');
+ if (!query || !query.trim()) {
+ // Show recent notes when no query
+ return notes.slice(0, 10);
+ }
+ const q = query.toLowerCase();
+ return notes
+ .filter(n =>
+ n.name.toLowerCase().includes(q) ||
+ n.path.toLowerCase().includes(q)
+ )
+ .slice(0, 10);
+ },
+
+ // Handle keyboard navigation in quick switcher
+ handleQuickSwitcherKeydown(e) {
+ const results = this.quickSwitcherResults;
+
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ this.quickSwitcherIndex = Math.min(this.quickSwitcherIndex + 1, results.length - 1);
+ this.scrollQuickSwitcherIntoView();
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ this.quickSwitcherIndex = Math.max(this.quickSwitcherIndex - 1, 0);
+ this.scrollQuickSwitcherIntoView();
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ const note = results[this.quickSwitcherIndex];
+ if (note) {
+ this.loadNote(note.path);
+ this.closeQuickSwitcher();
+ }
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ this.closeQuickSwitcher();
+ }
+ },
+
+ // Scroll selected item into view in quick switcher
+ scrollQuickSwitcherIntoView() {
+ this.$nextTick(() => {
+ const items = document.querySelectorAll('[data-quick-switcher-item]');
+ if (items[this.quickSwitcherIndex]) {
+ items[this.quickSwitcherIndex].scrollIntoView({ block: 'nearest' });
+ }
+ });
+ },
+
+ // Select note from quick switcher by click
+ selectQuickSwitcherNote(note) {
+ this.loadNote(note.path);
+ this.closeQuickSwitcher();
+ },
+
+ // Close share modal and reset state after animation
+ closeShareModal() {
+ this.showShareModal = false;
+ // Delay state reset until modal is fully hidden
+ setTimeout(() => {
+ this.showShareQR = false;
+ this.shareInfo = null;
+ this.shareLoading = false;
+ }, 200);
+ },
+
+ // Generate QR code for share URL
+ generateQRCode(url) {
+ if (!url || typeof qrcode === 'undefined') return '';
+ try {
+ const qr = qrcode(0, 'M'); // 0 = auto version, M = medium error correction
+ qr.addData(url);
+ qr.make();
+ return qr.createDataURL(4); // 4 = module size in pixels
+ } catch (e) {
+ console.error('QR code generation failed:', e);
+ return '';
+ }
+ },
+
+ // Open share modal and fetch current share status
+ async openShareModal() {
+ if (!this.currentNote) return;
+
+ // Reset state BEFORE showing modal to prevent flicker
+ this.showShareQR = false;
+ this.shareInfo = null;
+ this.shareLoading = true;
+ this.showShareModal = true;
+
+ try {
+ const notePath = this.currentNote.replace('.md', '');
+ const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
+ const response = await fetch(`/api/share/${encodedPath}`);
+
+ if (response.ok) {
+ this.shareInfo = await response.json();
+ } else {
+ this.shareInfo = { shared: false };
+ }
+ } catch (error) {
+ console.error('Failed to get share status:', error);
+ this.shareInfo = { shared: false };
+ } finally {
+ this.shareLoading = false;
+ }
+ },
+
+ // Create a share link for the current note (with current theme)
+ async createShareLink() {
+ if (!this.currentNote) return;
+
+ this.shareLoading = true;
+
+ try {
+ const notePath = this.currentNote.replace('.md', '');
+ const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
+ const response = await fetch(`/api/share/${encodedPath}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ theme: this.currentTheme || 'light' })
+ });
+
+ if (response.ok) {
+ this.shareInfo = await response.json();
+ this.shareInfo.shared = true;
+ // Update the shared paths set
+ this._sharedNotePaths.add(this.currentNote);
+ } else {
+ const error = await response.json();
+ alert(this.t('share.error_creating', { error: error.detail || 'Unknown error' }));
+ }
+ } catch (error) {
+ console.error('Failed to create share link:', error);
+ alert(this.t('share.error_creating', { error: error.message }));
+ } finally {
+ this.shareLoading = false;
+ }
+ },
+
+ // Copy share link to clipboard
+ async copyShareLink() {
+ if (!this.shareInfo?.url) return;
+
+ try {
+ await navigator.clipboard.writeText(this.shareInfo.url);
+ } catch (error) {
+ // Fallback for older browsers
+ const textArea = document.createElement('textarea');
+ textArea.value = this.shareInfo.url;
+ document.body.appendChild(textArea);
+ textArea.select();
+ document.execCommand('copy');
+ document.body.removeChild(textArea);
+ }
+
+ this.shareLinkCopied = true;
+ setTimeout(() => {
+ this.shareLinkCopied = false;
+ }, 2000);
+ },
+
+ // Revoke share link
+ async revokeShareLink() {
+ if (!this.currentNote) return;
+
+ if (!confirm(this.t('share.confirm_revoke'))) return;
+
+ this.shareLoading = true;
+
+ try {
+ const notePath = this.currentNote.replace('.md', '');
+ const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
+ const response = await fetch(`/api/share/${encodedPath}`, {
+ method: 'DELETE'
+ });
+
+ if (response.ok) {
+ this.shareInfo = { shared: false };
+ // Update the shared paths set
+ this._sharedNotePaths.delete(this.currentNote);
+ } else {
+ const error = await response.json();
+ alert(this.t('share.error_revoking', { error: error.detail || 'Unknown error' }));
+ }
+ } catch (error) {
+ console.error('Failed to revoke share link:', error);
+ alert(this.t('share.error_revoking', { error: error.message }));
+ } finally {
+ this.shareLoading = false;
+ }
+ },
+
+ // Toggle Zen Mode (full immersive writing experience)
+ async toggleZenMode() {
+ if (!this.zenMode) {
+ // Entering Zen Mode
+ this.previousViewMode = this.viewMode;
+ this.viewMode = 'edit';
+ this.mobileSidebarOpen = false;
+ this.zenMode = true;
+
+ // Request fullscreen
+ try {
+ const elem = document.documentElement;
+ if (elem.requestFullscreen) {
+ await elem.requestFullscreen();
+ } else if (elem.webkitRequestFullscreen) {
+ await elem.webkitRequestFullscreen();
+ } else if (elem.msRequestFullscreen) {
+ await elem.msRequestFullscreen();
+ }
+ } catch (e) {
+ // Fullscreen not supported or denied, continue anyway
+ console.log('Fullscreen not available:', e);
+ }
+
+ // Focus editor after transition
+ setTimeout(() => {
+ const editor = document.getElementById('note-editor');
+ if (editor) editor.focus();
+ }, 300);
+ } else {
+ // Exiting Zen Mode
+ this.zenMode = false;
+ this.viewMode = this.previousViewMode;
+
+ // Exit fullscreen
+ try {
+ if (document.exitFullscreen) {
+ await document.exitFullscreen();
+ } else if (document.webkitExitFullscreen) {
+ await document.webkitExitFullscreen();
+ } else if (document.msExitFullscreen) {
+ await document.msExitFullscreen();
+ }
+ } catch (e) {
+ console.log('Exit fullscreen error:', e);
+ }
+ }
+ },
+
+ // Homepage folder navigation methods
+ goToHomepageFolder(folderPath) {
+ this.showGraph = false; // Close graph when navigating
+ this.selectedHomepageFolder = folderPath || '';
+
+ // Clear editor state to show landing page
+ this.currentNote = '';
+ this.currentNoteName = '';
+ this.noteContent = '';
+ this.currentMedia = '';
+ this.outline = [];
+ this.backlinks = [];
+ document.title = this.appName;
+
+ // Invalidate cache to force recalculation
+ this._homepageCache = {
+ folderPath: null,
+ notes: null,
+ folders: null,
+ breadcrumb: null
+ };
+
+ window.history.pushState({ homepageFolder: folderPath || '' }, '', '/');
+ },
+
+ // Navigate to homepage root and clear all editor state
+ goHome() {
+ this.showGraph = false; // Close graph when going home
+ this.selectedHomepageFolder = '';
+ this.currentNote = '';
+ this.currentNoteName = '';
+ this.noteContent = '';
+ this.currentMedia = '';
+ this.outline = [];
+ this.backlinks = [];
+ this.mobileSidebarOpen = false;
+ document.title = this.appName;
+
+ // Clear undo/redo history
+ this.undoHistory = [];
+ this.redoHistory = [];
+ this.hasPendingHistoryChanges = false;
+
+ // Invalidate cache to force recalculation
+ this._homepageCache = {
+ folderPath: null,
+ notes: null,
+ folders: null,
+ breadcrumb: null
+ };
+
+ window.history.pushState({ homepageFolder: '' }, '', '/');
+ },
+
+ // Mobile files/home tab - context-aware behavior
+ mobileFilesTabClick() {
+ if (this.currentNote || this.currentMedia || this.showGraph) {
+ // Viewing content โ go home
+ this.goHome();
+ } else {
+ // On homepage โ toggle files sidebar
+ this.activePanel = 'files';
+ this.mobileSidebarOpen = !this.mobileSidebarOpen;
+ }
+ },
+
+ // ==================== GRAPH VIEW ====================
+
+ // Initialize the graph visualization
+ async initGraph() {
+ // Check if vis is loaded
+ if (typeof vis === 'undefined') {
+ console.error('vis-network library not loaded');
+ return;
+ }
+
+ this.graphLoaded = false;
+
+ try {
+ // Fetch graph data from API
+ const response = await fetch('/api/graph');
+ if (!response.ok) throw new Error('Failed to fetch graph data');
+ const data = await response.json();
+ this.graphData = data;
+
+ // Get container
+ const container = document.getElementById('graph-overlay');
+ if (!container) return;
+
+ // Get theme colors (force reflow to ensure CSS is applied)
+ document.body.offsetHeight; // Force reflow
+ const style = getComputedStyle(document.documentElement);
+
+ // Helper to get CSS variable with fallback
+ const getCssVar = (name, fallback) => {
+ const value = style.getPropertyValue(name).trim();
+ return value || fallback;
+ };
+
+ const accentPrimary = getCssVar('--accent-primary', '#7c3aed');
+ const accentSecondary = getCssVar('--accent-secondary', '#a78bfa');
+ const textPrimary = getCssVar('--text-primary', '#111827');
+ const textSecondary = getCssVar('--text-secondary', '#6b7280');
+ const bgPrimary = getCssVar('--bg-primary', '#ffffff');
+ const bgSecondary = getCssVar('--bg-secondary', '#f3f4f6');
+ const borderColor = getCssVar('--border-primary', '#e5e7eb');
+
+ // Prepare nodes with styling - all nodes same base color
+ const nodes = new vis.DataSet(data.nodes.map(n => ({
+ id: n.id,
+ label: n.label,
+ title: n.id, // Tooltip shows full path
+ color: {
+ background: accentPrimary,
+ border: accentPrimary,
+ highlight: {
+ background: accentPrimary,
+ border: textPrimary // Darker border when selected
+ },
+ hover: {
+ background: accentSecondary,
+ border: accentPrimary
+ }
+ },
+ font: {
+ color: textPrimary,
+ size: 12,
+ face: 'system-ui, -apple-system, sans-serif'
+ },
+ borderWidth: this.currentNote === n.id ? 4 : 2,
+ chosen: {
+ node: (values) => {
+ values.size = 22;
+ values.borderWidth = 4;
+ values.borderColor = textPrimary;
+ }
+ }
+ })));
+
+ // Prepare edges with styling based on type
+ const edges = new vis.DataSet(data.edges.map((e, i) => ({
+ id: i,
+ from: e.source,
+ to: e.target,
+ color: {
+ color: e.type === 'wikilink' ? accentPrimary : borderColor,
+ highlight: accentPrimary,
+ hover: accentSecondary,
+ opacity: 0.8
+ },
+ width: e.type === 'wikilink' ? 2 : 1,
+ smooth: {
+ type: 'continuous',
+ roundness: 0.5
+ },
+ chosen: {
+ edge: (values) => {
+ values.width = 3;
+ values.color = accentPrimary;
+ }
+ }
+ })));
+
+ // Network options
+ const options = {
+ nodes: {
+ shape: 'dot',
+ size: 16,
+ borderWidth: 2,
+ shadow: {
+ enabled: true,
+ color: 'rgba(0,0,0,0.1)',
+ size: 5,
+ x: 2,
+ y: 2
+ }
+ },
+ edges: {
+ arrows: {
+ to: {
+ enabled: true,
+ scaleFactor: 0.5,
+ type: 'arrow'
+ }
+ }
+ },
+ physics: {
+ enabled: true,
+ solver: 'forceAtlas2Based',
+ forceAtlas2Based: {
+ gravitationalConstant: -50,
+ centralGravity: 0.01,
+ springLength: 100,
+ springConstant: 0.08,
+ damping: 0.4,
+ avoidOverlap: 0.5
+ },
+ stabilization: {
+ enabled: true,
+ iterations: 200,
+ updateInterval: 25
+ }
+ },
+ interaction: {
+ hover: true,
+ tooltipDelay: 200,
+ navigationButtons: false, // Using custom buttons instead
+ keyboard: {
+ enabled: true,
+ bindToWindow: false
+ },
+ zoomView: true,
+ dragView: true
+ },
+ layout: {
+ improvedLayout: true,
+ randomSeed: 42
+ }
+ };
+
+ // Destroy existing instance if any
+ if (this.graphInstance) {
+ this.graphInstance.destroy();
+ this.graphInstance = null;
+ }
+
+ // Clear container to ensure clean state
+ const graphCanvas = container.querySelector('canvas');
+ if (graphCanvas) graphCanvas.remove();
+ const visElements = container.querySelectorAll('.vis-network, .vis-navigation');
+ visElements.forEach(el => el.remove());
+
+ // Create the network
+ this.graphInstance = new vis.Network(container, { nodes, edges }, options);
+
+ // Store reference for callbacks
+ const graphRef = this.graphInstance;
+ const currentNoteRef = this.currentNote;
+
+ // Wait for stabilization
+ this.graphInstance.once('stabilizationIterationsDone', () => {
+ graphRef.setOptions({ physics: { enabled: false } });
+ this.graphLoaded = true;
+
+ // Focus and select current note if one is loaded
+ if (currentNoteRef) {
+ setTimeout(() => {
+ try {
+ if (graphRef && this.showGraph) {
+ const nodeIds = graphRef.body.data.nodes.getIds();
+ if (nodeIds.includes(currentNoteRef)) {
+ // Focus on the node
+ graphRef.focus(currentNoteRef, {
+ scale: 1.2,
+ animation: {
+ duration: 500,
+ easingFunction: 'easeInOutQuad'
+ }
+ });
+ // Select the node to highlight it
+ graphRef.selectNodes([currentNoteRef]);
+ }
+ }
+ } catch (e) {
+ // Ignore - graph may have been destroyed
+ }
+ }, 150);
+ }
+ });
+
+ // Click event - open note
+ this.graphInstance.on('click', (params) => {
+ if (params.nodes.length > 0) {
+ const noteId = params.nodes[0];
+ this.loadNote(noteId);
+ // Node is already selected by vis-network on click, no need to call selectNodes
+ }
+ });
+
+ // Double-click event - open note and close graph
+ this.graphInstance.on('doubleClick', (params) => {
+ if (params.nodes.length > 0) {
+ const noteId = params.nodes[0];
+ // Close graph and load note
+ this.showGraph = false;
+ this.loadNote(noteId);
+ }
+ });
+
+ // Hover event - highlight connections
+ this.graphInstance.on('hoverNode', (params) => {
+ const nodeId = params.node;
+ const connectedNodes = this.graphInstance.getConnectedNodes(nodeId);
+ const connectedEdges = this.graphInstance.getConnectedEdges(nodeId);
+
+ // Dim all nodes except hovered and connected
+ const allNodes = nodes.getIds();
+ const updates = allNodes.map(id => ({
+ id,
+ opacity: (id === nodeId || connectedNodes.includes(id)) ? 1 : 0.2
+ }));
+ nodes.update(updates);
+ });
+
+ this.graphInstance.on('blurNode', () => {
+ // Reset all nodes to full opacity
+ const allNodes = nodes.getIds();
+ const updates = allNodes.map(id => ({ id, opacity: 1 }));
+ nodes.update(updates);
+ });
+
+ // Add legend to container
+ this.addGraphLegend(container, accentPrimary, borderColor, textSecondary);
+
+ } catch (error) {
+ console.error('Failed to initialize graph:', error);
+ this.graphLoaded = true; // Stop loading indicator
+ }
+ },
+
+ // Add legend to graph container
+ addGraphLegend(container, wikiColor, mdColor, textColor) {
+ // Remove existing legend if any
+ const existingLegend = container.querySelector('.graph-legend');
+ if (existingLegend) existingLegend.remove();
+
+ const legend = document.createElement('div');
+ legend.className = 'graph-legend';
+ legend.innerHTML = `
+
+
+ Wikilinks
+
+
+
+ ${this.t('graph.markdown_links')}
+
+
+ ${this.t('graph.click_hint')}
+
+ `;
+ container.appendChild(legend);
+ },
+
+ // Refresh graph when theme changes
+ refreshGraph() {
+ if (this.viewMode === 'graph' && this.graphInstance) {
+ this.initGraph();
+ }
+ }
+ }
+}
+
diff --git a/frontend/index.html b/frontend/index.html
index a9ebe8d..86e91e1 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,3446 +1,3445 @@
-
-
-
-
-
- NoteDiscovery
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Loading graph...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
๐ต
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ๐
-
- ยท
-
-
-
- ยท
-
-
-
-
- ยท
-
-
-
-
โผ Click for details
-
-
-
-
-
-
๐ Note Statistics
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Links
-
-
-
- ( internal)
-
-
-
-
-
-
-
-
-
Headings
-
-
-
- (H1: , H2: )
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Tasks
-
-
-
- (%)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{date}},
- {{time}},
- {{datetime}},
- {{year}},
- {{month}},
- {{day}},
- {{title}},
- {{folder}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- โโ
- โต
- esc
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
![QR Code]()
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ NoteDiscovery
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading graph...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
๐ต
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ๐
+
+ ยท
+
+
+
+ ยท
+
+
+
+
+ ยท
+
+
+
+
โผ Click for details
+
+
+
+
+
+
๐ Note Statistics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Links
+
+
+
+ ( internal)
+
+
+
+
+
+
+
+
+
Headings
+
+
+
+ (H1: , H2: )
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Tasks
+
+
+
+ (%)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{date}},
+ {{time}},
+ {{datetime}},
+ {{year}},
+ {{month}},
+ {{day}},
+ {{title}},
+ {{folder}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ โโ
+ โต
+ esc
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![QR Code]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/run.py b/run.py
index 0b47f4e..6ff5834 100644
--- a/run.py
+++ b/run.py
@@ -1,82 +1,96 @@
-#!/usr/bin/env python3
-"""
-Quick start script for NoteDiscovery
-Run this to start the application without Docker
-"""
-
-import sys
-import os
-import subprocess
-from pathlib import Path
-
-try:
- import colorama
- colorama.just_fix_windows_console()
-except ImportError:
- colorama = None
-
-def get_port():
- """Get port from: 1) ENV variable, 2) config.yaml, 3) default 8000"""
- # Priority 1: Environment variable
- if os.getenv("PORT"):
- return os.getenv("PORT")
-
- # Priority 2: config.yaml
- config_path = Path("config.yaml")
- if config_path.exists():
- try:
- import yaml
- with open(config_path, 'r', encoding='utf-8') as f:
- config = yaml.safe_load(f)
- if config and 'server' in config and 'port' in config['server']:
- return str(config['server']['port'])
- except Exception:
- pass # Fall through to default
-
- # Priority 3: Default
- return "8000"
-
-def main():
- print("๐ Starting NoteDiscovery...\n")
-
- # Check if requirements are installed
- try:
- import fastapi
- import uvicorn
- except ImportError:
- print("๐ฆ Installing dependencies...")
- 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()
-
- 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([
- sys.executable, "-m", "uvicorn",
- "backend.main:app",
- "--reload",
- "--host", "0.0.0.0",
- "--port", port,
- "--timeout-graceful-shutdown", "2"
- ])
-
-if __name__ == "__main__":
- main()
-
+#!/usr/bin/env python3
+"""
+Quick start script for NoteDiscovery
+Run this to start the application without Docker
+"""
+
+import sys
+import os
+import subprocess
+from pathlib import Path
+
+try:
+ import colorama
+ colorama.just_fix_windows_console()
+except ImportError:
+ colorama = None
+
+def get_port():
+ """Get port from: 1) ENV variable, 2) config.yaml, 3) default 8000"""
+ # Priority 1: Environment variable
+ if os.getenv("PORT"):
+ return os.getenv("PORT")
+
+ # Priority 2: config.yaml
+ config_path = Path("config.yaml")
+ if config_path.exists():
+ try:
+ import yaml
+ with open(config_path, 'r', encoding='utf-8') as f:
+ config = yaml.safe_load(f)
+ if config and 'server' in config and 'port' in config['server']:
+ return str(config['server']['port'])
+ except Exception:
+ pass # Fall through to default
+
+ # Priority 3: Default
+ return "8000"
+
+def main():
+ print("๐ Starting NoteDiscovery...\n")
+
+ # Check if requirements are installed
+ try:
+ import fastapi
+ import uvicorn
+ except ImportError:
+ print("๐ฆ Installing dependencies...")
+ 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()
+ # Load read_only flag (new)
+ read_only_enabled = "false"
+ config_path = Path("config.yaml")
+ if config_path.exists():
+ try:
+ import yaml
+ with open(config_path, 'r', encoding='utf-8') as f:
+ config = yaml.safe_load(f)
+ ro = config.get('read_only', {})
+ if ro.get('enabled') is True:
+ read_only_enabled = "true"
+ except Exception:
+ pass
+ os.environ["READ_ONLY_ENABLED"] = read_only_enabled
+ print(f"๐ Read-only public access: {'ENABLED' if read_only_enabled == 'true' else 'disabled'}")
+ 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([
+ sys.executable, "-m", "uvicorn",
+ "backend.main:app",
+ "--reload",
+ "--host", "0.0.0.0",
+ "--port", port,
+ "--timeout-graceful-shutdown", "2"
+ ])
+
+if __name__ == "__main__":
+ main()
+