commit 0afa382df92d0aba96e1be429155cfd051aad69e Author: Gamosoft Date: Wed Nov 5 17:48:41 2025 +0100 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f463a6c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info +dist +build +.git +.gitignore +.env +.venv +venv +README.md +.DS_Store +data/ +*.log + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6d22b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv + +# Data directories +search_index/ +*.db +*.sqlite + +# Plugin configuration (user-specific settings) +plugins/plugin_config.json + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Environment +.env + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7c6b14d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Copy application files +COPY backend ./backend +COPY frontend ./frontend +COPY config.yaml . +COPY plugins ./plugins +COPY themes ./themes + +# Create data directories +RUN mkdir -p data/notes data/search_index + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" + +# Run the application +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..003becc --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2025 Gamosoft + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e1966a --- /dev/null +++ b/README.md @@ -0,0 +1,143 @@ +# πŸ“ NoteDiscovery + +> Your Self-Hosted Knowledge Base + +## What is NoteDiscovery? + +NoteDiscovery is a **lightweight, self-hosted note-taking application** that puts you in complete control of your knowledge base. Write, organize, and discover your notes with a beautiful, modern interfaceβ€”all running on your own server. + +## 🎯 Who is it for? + +- **Privacy-conscious users** who want complete control over their data +- **Developers** who prefer markdown and local file storage +- **Knowledge workers** building a personal wiki or second brain +- **Teams** looking for a self-hosted alternative to commercial apps +- **Anyone** who values simplicity, speed, and ownership + +## ✨ Why NoteDiscovery? + +### vs. Commercial Apps (Notion, Evernote, Obsidian Sync) + +| Feature | NoteDiscovery | Commercial Apps | +|---------|---------------|-----------------| +| **Cost** | 100% Free | $xxx/month/year | +| **Privacy** | Your server, your data | Their servers, their terms | +| **Speed** | Lightning fast | Depends on internet | +| **Offline** | Always works | Limited or requires sync | +| **Customization** | Full control | Limited options | +| **No Lock-in** | Plain markdown files | Proprietary formats | + +### Key Benefits + +- πŸ”’ **Total Privacy** - Your notes never leave your server +- πŸ’° **Zero Cost** - No subscriptions, no hidden fees +- πŸš€ **Fast & Lightweight** - Instant search and navigation +- 🎨 **Beautiful Themes** - Multiple themes, easy to customize +- πŸ”Œ **Extensible** - Plugin system for custom features +- πŸ“± **Responsive** - Works on desktop, tablet, and mobile +- πŸ“‚ **Simple Storage** - Plain markdown files in folders + +## πŸš€ Quick Start + +### Running with Docker (Recommended) + +Docker ensures consistent environment and easy deployment: + +```bash +# Clone the repository +git clone https://github.com/gamosoft/notediscovery.git +cd notediscovery + +# Start with Docker Compose +docker-compose up -d + +# Access at http://localhost:8000 + +# View logs +docker-compose logs -f + +# Stop the application +docker-compose down +``` + +**Requirements:** +- Docker +- Docker Compose + +### Running Locally (Without Docker) + +For development or if you prefer running directly: + +```bash +# Clone the repository +git clone https://github.com/gamosoft/notediscovery.git +cd notediscovery + +# Install dependencies +pip install -r requirements.txt + +# Run the application +python run.py + +# Access at http://localhost:8000 +``` + +**Requirements:** +- Python 3.8 or higher +- pip (Python package manager) + +**Dependencies installed:** +- FastAPI - Web framework +- Uvicorn - ASGI server +- PyYAML - Configuration handling +- aiofiles - Async file operations + +## πŸ”’ Security Considerations + +NoteDiscovery is designed for **self-hosted, private use**. Please keep these security considerations in mind: + +### Network Security +- ⚠️ **Do NOT expose directly to the internet** without additional security measures +- Run behind a reverse proxy (nginx, Caddy) with HTTPS and authentication if needed +- Keep it on your local network or use a VPN for remote access +- By default, the app listens on `0.0.0.0:8000` (all network interfaces) + +### No Built-in Authentication +- The app has **no authentication by design** (single-user, self-hosted) +- Anyone with network access can read and modify your notes +- Use network-level security (firewall, VPN) for access control +- Consider adding authentication via reverse proxy if needed + +### Data Privacy +- Your notes are stored as **plain text markdown files** in `data/notes/` +- No data is sent to external services +- Regular backups are recommended + +### Best Practices +- Run on `localhost` or a private network only +- Use Docker for isolation and easier security management +- Keep your system and dependencies updated +- Review and audit any plugins you install +- Set appropriate file permissions on the `data/` directory + +**TL;DR**: Perfect for personal use on your local machine or home network. Add a reverse proxy with authentication if exposing to wider networks. + +## πŸ’– Support Development + +If NoteDiscovery makes your life easier, consider buying me a coffee! Your support helps keep this project alive and growing. + +**[β˜• Buy Me a Coffee](https://paypal.me/gamosoft)** + +Every contribution, no matter how small, is deeply appreciated and helps fund: +- πŸ› οΈ New features and improvements +- πŸ› Bug fixes and maintenance +- πŸ“š Documentation and tutorials +- 🎨 Design enhancements + +## πŸ“„ License + +MIT License - Free to use, modify, and distribute. + +--- + +Made with ❀️ for the self-hosting community diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..9d9e953 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1,2 @@ +# NoteDiscovery Backend + diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..cc60fcf --- /dev/null +++ b/backend/main.py @@ -0,0 +1,607 @@ +""" +NoteDiscovery - Self-Hosted Markdown Knowledge Base +Main FastAPI application +""" + +from fastapi import FastAPI, HTTPException, UploadFile, File +from fastapi.staticfiles import StaticFiles +from fastapi.responses import HTMLResponse, JSONResponse, FileResponse +from fastapi.middleware.cors import CORSMiddleware +import os +import yaml +import json +from pathlib import Path +from typing import List, Optional +import aiofiles +from datetime import datetime + +from .utils import ( + get_all_notes, + get_note_content, + save_note, + delete_note, + search_notes, + parse_wiki_links, + create_note_metadata, + ensure_directories, + create_folder, + get_all_folders, + move_note, + move_folder, + rename_folder, + delete_folder, +) +from .plugins import PluginManager +from .themes import get_available_themes, get_theme_css + +# 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) + +# Initialize app +app = FastAPI( + title=config['app']['name'], + description=config['app']['tagline'], + version=config['app']['version'] +) + +# CORS middleware for development +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 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") + + +@app.get("/", response_class=HTMLResponse) +async def root(): + """Serve the main application page""" + index_path = static_path / "index.html" + async with aiofiles.open(index_path, 'r', encoding='utf-8') as f: + content = await f.read() + return content + + +@app.get("/api") +async def api_documentation(): + """API Documentation - List all available endpoints""" + return { + "app": { + "name": config['app']['name'], + "version": config['app']['version'], + "description": config['app']['tagline'] + }, + "endpoints": [ + { + "method": "GET", + "path": "/api", + "description": "API documentation - lists all available endpoints", + "response": "API documentation object" + }, + { + "method": "GET", + "path": "/api/config", + "description": "Get application configuration", + "response": "{ name, tagline, version, searchEnabled }" + }, + { + "method": "GET", + "path": "/api/themes", + "description": "List all available themes", + "response": "{ themes: [{ id, name, builtin }] }" + }, + { + "method": "GET", + "path": "/api/themes/{theme_id}", + "description": "Get CSS content for a specific theme", + "parameters": {"theme_id": "Theme identifier (e.g., 'dark', 'light', 'dracula')"}, + "response": "{ css, theme_id }" + }, + { + "method": "GET", + "path": "/api/notes", + "description": "List all notes and folders", + "response": "{ notes: [{ path, name, folder }], folders: [path] }" + }, + { + "method": "GET", + "path": "/api/notes/{note_path}", + "description": "Get content of a specific note", + "parameters": {"note_path": "Path to note (e.g., 'test.md', 'folder/note.md')"}, + "response": "{ content }" + }, + { + "method": "POST", + "path": "/api/notes/{note_path}", + "description": "Create or update a note", + "parameters": {"note_path": "Path to note"}, + "body": {"content": "Markdown content of the note"}, + "response": "{ success, message }" + }, + { + "method": "DELETE", + "path": "/api/notes/{note_path}", + "description": "Delete a note", + "parameters": {"note_path": "Path to note"}, + "response": "{ success, message }" + }, + { + "method": "POST", + "path": "/api/notes/move", + "description": "Move a note to a different location", + "body": {"oldPath": "Current note path", "newPath": "New note path"}, + "response": "{ success, oldPath, newPath }" + }, + { + "method": "POST", + "path": "/api/folders", + "description": "Create a new folder", + "body": {"path": "Folder path (e.g., 'Projects', 'Work/2025')"}, + "response": "{ success, path }" + }, + { + "method": "POST", + "path": "/api/folders/move", + "description": "Move a folder to a different location", + "body": {"oldPath": "Current folder path", "newPath": "New folder path"}, + "response": "{ success, oldPath, newPath }" + }, + { + "method": "POST", + "path": "/api/folders/rename", + "description": "Rename a folder", + "body": {"oldPath": "Current folder path", "newPath": "New folder path"}, + "response": "{ success, oldPath, newPath }" + }, + { + "method": "GET", + "path": "/api/search", + "description": "Search notes by content", + "parameters": {"q": "Search query string"}, + "response": "{ results: [{ path, name, folder, snippet }], query }" + }, + { + "method": "GET", + "path": "/api/graph", + "description": "Get graph data for note visualization (wiki links)", + "response": "{ nodes: [{ id, label }], edges: [{ from, to }] }" + }, + { + "method": "GET", + "path": "/api/plugins", + "description": "List all loaded plugins", + "response": "{ plugins: [{ id, name, version, enabled }] }" + }, + { + "method": "POST", + "path": "/api/plugins/{plugin_name}/toggle", + "description": "Enable or disable a plugin", + "parameters": {"plugin_name": "Plugin identifier"}, + "body": {"enabled": "true/false"}, + "response": "{ success, plugin, enabled }" + }, + { + "method": "GET", + "path": "/health", + "description": "Health check endpoint", + "response": "{ status: 'healthy', app, version }" + } + ], + "notes": { + "authentication": "Not required (add authentication in config.yaml if needed)", + "base_url": "http://localhost:8000", + "content_type": "application/json", + "cors": "Enabled for all origins" + }, + "examples": { + "create_note": { + "curl": "curl -X POST http://localhost:8000/api/notes/test.md -H 'Content-Type: application/json' -d '{\"content\": \"# Hello World\"}'", + "description": "Create a new note named test.md" + }, + "search_notes": { + "curl": "curl http://localhost:8000/api/search?q=hello", + "description": "Search for notes containing 'hello'" + }, + "list_themes": { + "curl": "curl http://localhost:8000/api/themes", + "description": "Get all available themes" + }, + "enable_plugin": { + "curl": "curl -X POST http://localhost:8000/api/plugins/git_backup/toggle -H 'Content-Type: application/json' -d '{\"enabled\": true}'", + "description": "Enable the git_backup plugin" + } + } + } + + +@app.get("/api/config") +async def get_config(): + """Get app configuration for frontend""" + return { + "name": config['app']['name'], + "tagline": config['app']['tagline'], + "version": config['app']['version'], + "searchEnabled": config['search']['enabled'] + } + + +@app.get("/api/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}") +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} + + +@app.post("/api/folders") +async def create_new_folder(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 Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/notes/move") +async def move_note_endpoint(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 = move_note(config['storage']['notes_dir'], old_path, new_path) + + if not success: + raise HTTPException(status_code=500, detail="Failed to move note") + + # 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 Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/folders/move") +async def move_folder_endpoint(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 = move_folder(config['storage']['notes_dir'], old_path, new_path) + + if not success: + raise HTTPException(status_code=500, detail="Failed to move folder") + + return { + "success": True, + "oldPath": old_path, + "newPath": new_path, + "message": "Folder moved successfully" + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/folders/rename") +async def rename_folder_endpoint(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 = rename_folder(config['storage']['notes_dir'], old_path, new_path) + + if not success: + raise HTTPException(status_code=500, detail="Failed to rename folder") + + return { + "success": True, + "oldPath": old_path, + "newPath": new_path, + "message": "Folder renamed successfully" + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.delete("/api/folders/{folder_path:path}") +async def delete_folder_endpoint(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 Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/notes") +async def list_notes(): + """List all notes with metadata""" + try: + notes = get_all_notes(config['storage']['notes_dir']) + folders = get_all_folders(config['storage']['notes_dir']) + return {"notes": notes, "folders": folders} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/notes/{note_path:path}") +async def get_note(note_path: str): + """Get a specific note's content""" + 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 + + # Parse wiki links + links = parse_wiki_links(content) + + return { + "path": note_path, + "content": content, + "links": links, + "metadata": create_note_metadata(config['storage']['notes_dir'], note_path) + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/notes/{note_path:path}") +async def create_or_update_note(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 Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.delete("/api/notes/{note_path:path}") +async def remove_note(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") + + # Run plugin hooks + plugin_manager.run_hook('on_note_delete', note_path=note_path) + + return { + "success": True, + "message": "Note deleted successfully" + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/search") +async def search(q: str): + """Search notes by content""" + try: + if not config['search']['enabled']: + raise HTTPException(status_code=403, detail="Search is disabled") + + results = search_notes(config['storage']['notes_dir'], q) + + # Run plugin hooks + plugin_manager.run_hook('on_search', query=q, results=results) + + return {"results": results, "query": q} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/graph") +async def get_graph(): + """Get graph data for visualization""" + try: + notes = get_all_notes(config['storage']['notes_dir']) + nodes = [] + edges = [] + + # Build graph structure + for note in notes: + nodes.append({ + "id": note['path'], + "label": note['name'] + }) + + # Get links from this note + content = get_note_content(config['storage']['notes_dir'], note['path']) + if content: + links = parse_wiki_links(content) + for link in links: + edges.append({ + "from": note['path'], + "to": link + }) + + return {"nodes": nodes, "edges": edges} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/plugins") +async def list_plugins(): + """List all available plugins""" + return {"plugins": plugin_manager.list_plugins()} + + +@app.get("/api/plugins/note_stats/calculate") +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=str(e)) + + +@app.post("/api/plugins/{plugin_name}/toggle") +async def toggle_plugin(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=str(e)) + + +@app.get("/health") +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 +@app.get("/{full_path:path}", response_class=HTMLResponse) +async def catch_all(full_path: str): + """ + Serve index.html for all non-API routes. + 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 for all other routes + index_path = static_path / "index.html" + async with aiofiles.open(index_path, 'r', encoding='utf-8') as f: + content = await f.read() + return content + + +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/backend/plugins.py b/backend/plugins.py new file mode 100644 index 0000000..73a8a03 --- /dev/null +++ b/backend/plugins.py @@ -0,0 +1,274 @@ +""" +Simple plugin system for NoteDiscovery +Plugins can hook into events like note save, delete, etc. +""" + +import os +import json +import importlib.util +from pathlib import Path +from typing import List, Dict, Callable + + +class Plugin: + """Base plugin class""" + + def __init__(self): + self.name = "Base Plugin" + self.version = "1.0.0" + self.enabled = False + + def on_note_save(self, note_path: str, content: str) -> str | None: + """ + Called when a note is being saved. + Can optionally transform content before writing to disk (e.g., encrypt). + + Args: + note_path: Path to the note being saved + content: Content to be saved + + Returns: + Transformed content, or None to keep original + """ + return None + + def on_note_delete(self, note_path: str): + """Called when a note is deleted""" + pass + + def on_search(self, query: str, results: List[Dict]): + """Called after a search is performed""" + pass + + def on_note_create(self, note_path: str, initial_content: str) -> str: + """ + Called when a new note is created (before first save). + Can modify and return the initial content. + + Args: + note_path: Path to the new note + initial_content: The initial content for the note + + Returns: + Modified content (or return initial_content unchanged) + """ + return initial_content + + def on_note_load(self, note_path: str, content: str) -> str | None: + """ + Called when a note is loaded from disk. + Can optionally transform content before displaying (e.g., decrypt). + + Args: + note_path: Path to the loaded note + content: Content loaded from disk + + Returns: + Transformed content, or None to keep original + """ + return None + + def on_app_startup(self): + """ + Called when the application starts up. + Useful for initialization, sync, health checks, etc. + """ + pass + + +class PluginManager: + """Manages loading and execution of plugins""" + + def __init__(self, plugins_dir: str): + self.plugins_dir = Path(plugins_dir) + self.plugins: Dict[str, Plugin] = {} + self.config_file = self.plugins_dir / "plugin_config.json" + self.load_plugins() + self._apply_saved_state() + # Save config to create/update the file with current states + if self.plugins: # Only save if there are plugins loaded + self._save_config() + + def load_plugins(self): + """Load all plugins from the plugins directory""" + if not self.plugins_dir.exists(): + self.plugins_dir.mkdir(parents=True, exist_ok=True) + self._create_example_plugin() + return + + for plugin_file in self.plugins_dir.glob("*.py"): + if plugin_file.stem.startswith("_"): + continue + + try: + spec = importlib.util.spec_from_file_location( + plugin_file.stem, plugin_file + ) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # Look for Plugin class in module + if hasattr(module, 'Plugin'): + plugin = module.Plugin() + self.plugins[plugin_file.stem] = plugin + except Exception as e: + print(f"Failed to load plugin {plugin_file.stem}: {e}") + + def _create_example_plugin(self): + """Create an example plugin to show developers how to build plugins""" + example_plugin = '''""" +Example Plugin for NoteDiscovery +This plugin demonstrates how to create custom plugins. +""" + +class Plugin: + def __init__(self): + self.name = "Example Plugin" + self.version = "1.0.0" + self.enabled = True + + def on_note_save(self, note_path: str, content: str): + """This runs every time a note is saved""" + print(f"βœ“ Plugin: Note saved - {note_path}") + + # Example: Automatically add tags to notes + # if '#todo' in content: + # print(" β†’ Found TODO tag!") + + def on_note_delete(self, note_path: str): + """This runs when a note is deleted""" + print(f"βœ— Plugin: Note deleted - {note_path}") + + def on_search(self, query: str, results: list): + """This runs after a search is performed""" + print(f"πŸ” Plugin: Search performed for '{query}' ({len(results)} results)") +''' + example_path = self.plugins_dir / "example_plugin.py" + with open(example_path, 'w', encoding='utf-8') as f: + f.write(example_plugin) + + def list_plugins(self) -> List[Dict]: + """Get a list of all loaded plugins""" + return [ + { + "id": plugin_id, + "name": plugin.name, + "version": plugin.version, + "enabled": plugin.enabled + } + for plugin_id, plugin in self.plugins.items() + ] + + def _load_config(self) -> Dict[str, bool]: + """Load plugin configuration from JSON file""" + if self.config_file.exists(): + try: + with open(self.config_file, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + print(f"Failed to load plugin config: {e}") + return {} + + def _save_config(self): + """Save current plugin states to JSON file""" + try: + config = { + plugin_id: plugin.enabled + for plugin_id, plugin in self.plugins.items() + } + with open(self.config_file, 'w', encoding='utf-8') as f: + json.dump(config, f, indent=2) + except Exception as e: + print(f"Failed to save plugin config: {e}") + + def _apply_saved_state(self): + """Apply saved plugin states after loading plugins""" + saved_config = self._load_config() + for plugin_id, enabled in saved_config.items(): + if plugin_id in self.plugins: + self.plugins[plugin_id].enabled = enabled + print(f"Plugin '{plugin_id}': {'enabled' if enabled else 'disabled'} (from config)") + + def enable_plugin(self, plugin_id: str): + """Enable a plugin and persist the state""" + if plugin_id in self.plugins: + self.plugins[plugin_id].enabled = True + self._save_config() + + def disable_plugin(self, plugin_id: str): + """Disable a plugin and persist the state""" + if plugin_id in self.plugins: + self.plugins[plugin_id].enabled = False + self._save_config() + + def run_hook(self, hook_name: str, **kwargs): + """ + Run a hook on all enabled plugins. + + For hooks that can transform content (on_note_save, on_note_load): + - Pass 'content' in kwargs + - Returns the transformed content after all plugins process it + + For void hooks (on_note_delete, on_search, on_app_startup): + - Just executes the hooks + - Returns None + + Args: + hook_name: Name of the hook to run + **kwargs: Arguments to pass to the hook + + Returns: + Transformed content if 'content' in kwargs, otherwise None + """ + result = kwargs.get('content') + + for plugin in self.plugins.values(): + if plugin.enabled and hasattr(plugin, hook_name): + try: + method = getattr(plugin, hook_name) + + # For hooks that can transform content + if 'content' in kwargs: + transformed = method(**{**kwargs, 'content': result}) + if transformed is not None: + result = transformed + else: + # For void hooks (no return value) + method(**kwargs) + + except Exception as e: + print(f"Plugin {plugin.name} error in {hook_name}: {e}") + + return result if 'content' in kwargs else None + + def run_hook_with_return(self, hook_name: str, **kwargs): + """ + Run a hook that can modify and return a value (e.g., on_note_create). + Each plugin processes the value from the previous plugin. + + The hook method should accept all kwargs and return the modified value. + For on_note_create: expects (note_path, initial_content) and returns modified content. + + Args: + hook_name: Name of the hook to run + **kwargs: Arguments to pass to the hook (including the value to modify) + + Returns: + Modified value after all plugins have processed it + """ + for plugin in self.plugins.values(): + if plugin.enabled and hasattr(plugin, hook_name): + try: + method = getattr(plugin, hook_name) + result = method(**kwargs) + # Update the modifiable value for the next plugin + if 'initial_content' in kwargs and result is not None: + kwargs['initial_content'] = result + except Exception as e: + print(f"Plugin {plugin.name} error in {hook_name}: {e}") + + # Return the final modified value + return kwargs.get('initial_content', '') + + diff --git a/backend/themes.py b/backend/themes.py new file mode 100644 index 0000000..59af779 --- /dev/null +++ b/backend/themes.py @@ -0,0 +1,63 @@ +""" +Theme management for NoteDiscovery +""" + +from pathlib import Path +from typing import List, Dict +import re + + +def get_available_themes(themes_dir: str) -> List[Dict[str, str]]: + """Get all available themes from the themes directory""" + themes_path = Path(themes_dir) + themes = [] + + # Theme icons/emojis mapping + theme_icons = { + "light": "🌞", + "dark": "πŸŒ™", + "dracula": "πŸ§›", + "nord": "❄️", + "monokai": "🎞️", + "vue-high-contrast": "πŸ’š", + "cobalt2": "🌊", + "vs-blue": "πŸ”·" + } + + # Built-in themes + default_themes = [ + {"id": "light", "name": f"{theme_icons.get('light', '')} Light", "builtin": True}, + {"id": "dark", "name": f"{theme_icons.get('dark', '')} Dark", "builtin": True}, + ] + + themes.extend(default_themes) + + # Custom themes + if themes_path.exists(): + for theme_file in themes_path.glob("*.css"): + # Skip built-in themes + if theme_file.stem in ["light", "dark"]: + continue + + theme_name = theme_file.stem.replace("-", " ").replace("_", " ").title() + icon = theme_icons.get(theme_file.stem, "🎨") + + themes.append({ + "id": theme_file.stem, + "name": f"{icon} {theme_name}", + "builtin": False + }) + + return themes + + +def get_theme_css(themes_dir: str, theme_id: str) -> str: + """Get the CSS content for a specific theme""" + theme_path = Path(themes_dir) / f"{theme_id}.css" + + if not theme_path.exists(): + return "" + + with open(theme_path, 'r', encoding='utf-8') as f: + return f.read() + diff --git a/backend/utils.py b/backend/utils.py new file mode 100644 index 0000000..1f1d654 --- /dev/null +++ b/backend/utils.py @@ -0,0 +1,299 @@ +""" +Utility functions for file operations, search, and markdown processing +""" + +import os +import re +import shutil +from pathlib import Path +from typing import List, Dict, Optional +from datetime import datetime + + +def validate_path_security(notes_dir: str, path: Path) -> bool: + """ + Validate that a path is within the notes directory (security check). + Prevents path traversal attacks. + + Args: + notes_dir: Base notes directory + path: Path to validate + + Returns: + True if path is safe, False otherwise + """ + try: + path.resolve().relative_to(Path(notes_dir).resolve()) + return True + except ValueError: + return False + + +def ensure_directories(config: dict): + """Create necessary directories if they don't exist""" + dirs = [ + config['storage']['notes_dir'], + config['storage']['plugins_dir'], + ] + if config['search']['enabled']: + dirs.append(config['search']['index_dir']) + + for dir_path in dirs: + Path(dir_path).mkdir(parents=True, exist_ok=True) + + +def create_folder(notes_dir: str, folder_path: str) -> bool: + """Create a new folder in the notes directory""" + full_path = Path(notes_dir) / folder_path + + # Security check + if not validate_path_security(notes_dir, full_path): + return False + + full_path.mkdir(parents=True, exist_ok=True) + + return True + + +def get_all_folders(notes_dir: str) -> List[str]: + """Get all folders in the notes directory, including empty ones""" + folders = [] + notes_path = Path(notes_dir) + + for item in notes_path.rglob("*"): + if item.is_dir(): + relative_path = item.relative_to(notes_path) + folder_path = str(relative_path.as_posix()) + if folder_path and not folder_path.startswith('.'): + folders.append(folder_path) + + return sorted(folders) + + +def move_note(notes_dir: str, old_path: str, new_path: str) -> bool: + """Move a note to a different location""" + old_full_path = Path(notes_dir) / old_path + new_full_path = Path(notes_dir) / new_path + + # Security checks + if not validate_path_security(notes_dir, old_full_path) or \ + not validate_path_security(notes_dir, new_full_path): + return False + + if not old_full_path.exists(): + return False + + # Create parent directory if needed + new_full_path.parent.mkdir(parents=True, exist_ok=True) + + # Move the file + old_full_path.rename(new_full_path) + + # Note: We don't automatically delete empty folders to preserve user's folder structure + + return True + + +def move_folder(notes_dir: str, old_path: str, new_path: str) -> bool: + """Move a folder to a different location""" + import shutil + + old_full_path = Path(notes_dir) / old_path + new_full_path = Path(notes_dir) / new_path + + # Security checks + if not validate_path_security(notes_dir, old_full_path) or \ + not validate_path_security(notes_dir, new_full_path): + return False + + if not old_full_path.exists() or not old_full_path.is_dir(): + return False + + # Check if target already exists + if new_full_path.exists(): + return False + + # Create parent directory if needed + new_full_path.parent.mkdir(parents=True, exist_ok=True) + + # Move the folder + shutil.move(str(old_full_path), str(new_full_path)) + + # Note: We don't automatically delete empty folders to preserve user's folder structure + + return True + + +def rename_folder(notes_dir: str, old_path: str, new_path: str) -> bool: + """Rename a folder (same as move but for clarity)""" + return move_folder(notes_dir, old_path, new_path) + + +def delete_folder(notes_dir: str, folder_path: str) -> bool: + """Delete a folder and all its contents""" + try: + full_path = Path(notes_dir) / folder_path + + if not full_path.exists(): + print(f"Folder does not exist: {full_path}") + return False + + if not full_path.is_dir(): + print(f"Path is not a directory: {full_path}") + return False + + # Delete the folder and all its contents + shutil.rmtree(full_path) + print(f"Successfully deleted folder: {full_path}") + return True + except Exception as e: + print(f"Error deleting folder '{folder_path}': {e}") + import traceback + traceback.print_exc() + return False + + +def get_all_notes(notes_dir: str) -> List[Dict]: + """Recursively get all markdown notes""" + notes = [] + notes_path = Path(notes_dir) + + for md_file in notes_path.rglob("*.md"): + relative_path = md_file.relative_to(notes_path) + stat = md_file.stat() + + notes.append({ + "name": md_file.stem, + "path": str(relative_path.as_posix()), + "folder": str(relative_path.parent.as_posix()) if str(relative_path.parent) != "." else "", + "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "size": stat.st_size + }) + + return sorted(notes, key=lambda x: x['modified'], reverse=True) + + +def get_note_content(notes_dir: str, note_path: str) -> Optional[str]: + """Get the content of a specific note""" + full_path = Path(notes_dir) / note_path + + if not full_path.exists() or not full_path.is_file(): + return None + + # Security check: ensure the path is within notes_dir + if not validate_path_security(notes_dir, full_path): + return None + + with open(full_path, 'r', encoding='utf-8') as f: + return f.read() + + +def save_note(notes_dir: str, note_path: str, content: str) -> bool: + """Save or update a note""" + full_path = Path(notes_dir) / note_path + + # Ensure .md extension + if not note_path.endswith('.md'): + full_path = full_path.with_suffix('.md') + + # Security check + if not validate_path_security(notes_dir, full_path): + return False + + # Create parent directories if needed + full_path.parent.mkdir(parents=True, exist_ok=True) + + with open(full_path, 'w', encoding='utf-8') as f: + f.write(content) + + return True + + +def delete_note(notes_dir: str, note_path: str) -> bool: + """Delete a note""" + full_path = Path(notes_dir) / note_path + + if not full_path.exists(): + return False + + # Security check + if not validate_path_security(notes_dir, full_path): + return False + + full_path.unlink() + + # Remove empty parent directories + try: + full_path.parent.rmdir() + except OSError: + pass # Directory not empty, that's fine + + return True + + +def parse_wiki_links(content: str) -> List[str]: + """Extract wiki-style links [[link]] from markdown content""" + pattern = r'\[\[([^\]]+)\]\]' + matches = re.findall(pattern, content) + return matches + + +def search_notes(notes_dir: str, query: str) -> List[Dict]: + """Simple full-text search through all notes""" + results = [] + query_lower = query.lower() + notes_path = Path(notes_dir) + + for md_file in notes_path.rglob("*.md"): + try: + with open(md_file, 'r', encoding='utf-8') as f: + content = f.read() + + if query_lower in content.lower(): + # Find context around match + lines = content.split('\n') + matched_lines = [] + + for i, line in enumerate(lines): + if query_lower in line.lower(): + # Get surrounding context + start = max(0, i - 1) + end = min(len(lines), i + 2) + context = '\n'.join(lines[start:end]) + matched_lines.append({ + "line_number": i + 1, + "context": context[:200] # Limit context length + }) + + relative_path = md_file.relative_to(notes_path) + results.append({ + "name": md_file.stem, + "path": str(relative_path.as_posix()), + "matches": matched_lines[:3] # Limit to 3 matches per file + }) + except Exception: + continue + + return results + + +def create_note_metadata(notes_dir: str, note_path: str) -> Dict: + """Get metadata for a note""" + full_path = Path(notes_dir) / note_path + + if not full_path.exists(): + return {} + + stat = full_path.stat() + + # Count lines with proper file handle management + with open(full_path, 'r', encoding='utf-8') as f: + line_count = sum(1 for _ in f) + + return { + "created": datetime.fromtimestamp(stat.st_ctime).isoformat(), + "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "size": stat.st_size, + "lines": line_count + } + diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..94d4498 --- /dev/null +++ b/config.yaml @@ -0,0 +1,25 @@ +# NoteDiscovery Configuration +# Easy to rebrand: just change these values! + +app: + name: "NoteDiscovery" + tagline: "Your Self-Hosted Knowledge Base" + version: "1.0.0" + +server: + host: "0.0.0.0" + port: 8000 + reload: false # Set to true for development + +storage: + notes_dir: "./data/notes" + plugins_dir: "./plugins" + +search: + enabled: true + index_dir: "./data/search_index" + +security: + # Add authentication later if needed + require_auth: false + diff --git a/data/notes/API.md b/data/notes/API.md new file mode 100644 index 0000000..3ab34c8 --- /dev/null +++ b/data/notes/API.md @@ -0,0 +1,252 @@ +# πŸ“‘ API Documentation + +Base URL: `http://localhost:8000` + +## πŸ—‚οΈ Notes + +### List All Notes +```http +GET /api/notes +``` +Returns all notes with their metadata and folder structure. + +**Example:** +```bash +curl http://localhost:8000/api/notes +``` + +### Get Note Content +```http +GET /api/notes/{note_path} +``` +Retrieve the content of a specific note. + +**Example:** +```bash +curl http://localhost:8000/api/notes/folder/mynote.md +``` + +### Create/Update Note +```http +POST /api/notes/{note_path} +Content-Type: application/json + +{ + "content": "# My Note\nNote content here..." +} +``` + +**Response:** +```json +{ + "success": true, + "path": "test.md", + "message": "Note created successfully", + "content": "# My Note\nNote content here..." +} +``` + +**Note:** When creating a new note, the `on_note_create` hook is triggered, allowing plugins (like Template Generator) to modify the initial content. The response includes the potentially modified content. + +**Linux/Mac:** +```bash +curl -X POST http://localhost:8000/api/notes/test.md \ + -H "Content-Type: application/json" \ + -d '{"content": "# Hello World"}' +``` + +**Windows PowerShell:** +```powershell +curl.exe -X POST http://localhost:8000/api/notes/test.md -H "Content-Type: application/json" -d "{\"content\": \"# Hello World\"}" +``` + +### Delete Note +```http +DELETE /api/notes/{note_path} +``` + +**Example:** +```bash +curl -X DELETE http://localhost:8000/api/notes/test.md +``` + +### Move Note +```http +POST /api/notes/move +Content-Type: application/json + +{ + "oldPath": "note.md", + "newPath": "folder/note.md" +} +``` + +## πŸ“ Folders + +### Create Folder +```http +POST /api/folders +Content-Type: application/json + +{ + "path": "Projects/2025" +} +``` + +### Move Folder +```http +POST /api/folders/move +Content-Type: application/json + +{ + "oldPath": "OldFolder", + "newPath": "NewFolder" +} +``` + +### Rename Folder +```http +POST /api/folders/rename +Content-Type: application/json + +{ + "oldPath": "Projects", + "newName": "Work" +} +``` + +## πŸ” Search + +### Search Notes +```http +GET /api/search?q={query} +``` + +**Example:** +```bash +curl "http://localhost:8000/api/search?q=hello" +``` + +## 🎨 Themes + +### List Themes +```http +GET /api/themes +``` + +### Get Theme CSS +```http +GET /api/themes/{theme_id} +``` + +**Example:** +```bash +curl http://localhost:8000/api/themes/dark +``` + +## πŸ”Œ Plugins + +Plugins can hook into various events in the application lifecycle. + +### Available Plugin Hooks + +| Hook | Triggered When | Can Modify Data | +|------|----------------|-----------------| +| `on_note_create` | New note is created | βœ… Yes (return modified content) | +| `on_note_save` | Note is being saved | βœ… Yes (return transformed content, or None) | +| `on_note_load` | Note is loaded | βœ… Yes (return transformed content, or None) | +| `on_note_delete` | Note is deleted | ❌ No | +| `on_search` | Search is performed | ❌ No | +| `on_app_startup` | App starts | ❌ No | + +See [PLUGINS.md](PLUGINS.md) for full documentation on creating plugins. + +### List Plugins +```http +GET /api/plugins +``` + +### Toggle Plugin +```http +POST /api/plugins/{plugin_name}/toggle +Content-Type: application/json + +{ + "enabled": true +} +``` + +**Linux/Mac:** +```bash +curl -X POST http://localhost:8000/api/plugins/note_stats/toggle \ + -H "Content-Type: application/json" \ + -d '{"enabled": true}' +``` + +**Windows PowerShell:** +```powershell +curl.exe -X POST http://localhost:8000/api/plugins/note_stats/toggle -H "Content-Type: application/json" -d "{\"enabled\": true}" +``` + +### Calculate Note Stats +```http +GET /api/plugins/note_stats/calculate?content={markdown_content} +``` + +## πŸ”— Graph + +### Get Note Graph +```http +GET /api/graph +``` +Returns the relationship graph between notes (internal links). + +## βš™οΈ System + +### Get Config +```http +GET /api/config +``` +Returns application configuration. + +### Health Check +```http +GET /health +``` +Returns system health status. + +### API Info +```http +GET /api +``` +Self-documenting endpoint listing all available API routes. + +--- + +## πŸ“ Response Format + +All endpoints return JSON responses: + +**Success:** +```json +{ + "success": true, + "data": { ... } +} +``` + +**Error:** +```json +{ + "detail": "Error message" +} +``` + +## πŸ”‘ Authentication + +Currently, no authentication is required. Suitable for self-hosted local environments. + +--- + +πŸ’‘ **Tip:** Use the `/api` endpoint to get a live, self-documented list of all available endpoints! + diff --git a/data/notes/FEATURES.md b/data/notes/FEATURES.md new file mode 100644 index 0000000..fc4db72 --- /dev/null +++ b/data/notes/FEATURES.md @@ -0,0 +1,107 @@ +# ✨ Features + +## πŸ“ Note Management + +### Create & Edit +- **Rich markdown editor** with live preview +- **Three view modes**: Edit, Split, Preview +- **Auto-save** - Never lose your work +- **Undo/Redo** - Ctrl+Z / Ctrl+Y support +- **Syntax highlighting** for code blocks (50+ languages) +- **Copy code blocks** - One-click copy button on hover + +### Organization +- **Folder hierarchy** - Organize notes in nested folders +- **Drag & drop** - Move notes and folders effortlessly +- **Alphabetical sorting** - Find notes quickly +- **Rename anything** - Files and folders, instantly +- **Visual tree view** - Expandable/collapsible navigation + +## πŸ”— Linking & Discovery + +### Internal Links +- **Wiki-style links** - `[[Note Name]]` syntax +- **Drag to link** - Hold Ctrl and drag a note into the editor +- **Click to navigate** - Jump between notes seamlessly +- **External links** - Open in new tabs automatically + +### Direct URLs +- **Deep linking** - Open specific notes via URL (e.g., `/folder/note`) +- **Search highlighting** - Add `?search=term` to highlight specific content +- **Browser history** - Back/forward buttons navigate between notes +- **Shareable links** - Bookmark or share direct links to notes with highlighted terms +- **Refresh safe** - Page reload keeps you on the same note with search context + +## 🎨 Customization + +### Themes +- **8 built-in themes** - Light, Dark, Dracula, Nord, Monokai, Vue High Contrast, Cobalt2, VS Blue +- **Theme persistence** - Remembers your choice +- **Custom themes** - Create your own CSS themes +- **Instant switching** - No reload required + +### Layout +- **Resizable sidebar** - Drag to adjust width +- **View mode memory** - Remembers Edit/Split/Preview preference +- **Responsive design** - Works on all screen sizes + +## πŸ“Š Note Statistics + +### Built-in Plugin +- **Word count** - Track document length +- **Character count** - Including and excluding spaces +- **Reading time** - Estimated minutes to read +- **Line count** - Total lines in note +- **Image count** - Track embedded images +- **Link count** - Internal and external links +- **Expandable panel** - Toggle stats visibility + +## πŸ”Œ Plugin System + +### Extensibility +- **Easy installation** - Drop Python files in `plugins/` folder +- **Hot reload** - Plugins detected on app restart +- **Toggle on/off** - Enable/disable without deleting +- **Event hooks** - React to note saves, deletes, searches +- **API access** - Full access to backend functionality + +### Example Use Cases +- Git auto-sync +- Note encryption +- Export to PDF +- Daily note creation +- Backlinks tracking +- Tag management + +## πŸ” Search + +- **Full-text search** - Find notes by content +- **Real-time results** - As you type +- **Highlight matches** - See context in results +- **In-note highlighting** - Search terms highlighted in open notes +- **Live highlighting** - Highlights update as you type or edit +- **Fast indexing** - Instant search across notes + +## ⚑ Keyboard Shortcuts + +| Windows/Linux | Mac | Action | +|---------------|-----|--------| +| `Ctrl+S` | `Cmd+S` | Save note | +| `Ctrl+Alt+N` | `Cmd+Option+N` | New note | +| `Ctrl+Z` | `Cmd+Z` | Undo | +| `Ctrl+Y` or `Ctrl+Shift+Z` | `Cmd+Y` or `Cmd+Shift+Z` | Redo | +| `F3` | `F3` | Next search match | +| `Shift+F3` | `Shift+F3` | Previous search match | +| `Ctrl+Drag` | `Cmd+Drag` | Create internal link | + +## πŸš€ Performance + +- **Instant loading** - No lag, no loading spinners +- **Efficient caching** - Smart local storage +- **Minimal resources** - Runs on modest hardware +- **No bloat** - Focused on what matters + +--- + +πŸ’‘ **Tip:** Explore the interface! Most features are discoverable through intuitive drag & drop and hover menus. + diff --git a/data/notes/PLUGINS.md b/data/notes/PLUGINS.md new file mode 100644 index 0000000..f21f0aa --- /dev/null +++ b/data/notes/PLUGINS.md @@ -0,0 +1,125 @@ +# πŸ”Œ Plugin System + +NoteDiscovery includes a powerful plugin system that lets you extend functionality without modifying core code. + +## How Plugins Work + +Plugins are Python files that live in the `plugins/` directory. They use **event hooks** to react to actions in the app: + +### Available Hooks + +| Hook | When Triggered | Parameters | Can Modify | +|------|----------------|------------|------------| +| `on_note_create` | New note is created | `note_path`, `initial_content` | βœ… Yes (return modified content) | +| `on_note_save` | Note is being saved | `note_path`, `content` | βœ… Yes (return transformed content, or None) | +| `on_note_load` | Note is loaded from disk | `note_path`, `content` | βœ… Yes (return transformed content, or None) | +| `on_note_delete` | Note is deleted | `note_path` | ❌ No | +| `on_search` | Search is performed | `query`, `results` | ❌ No | +| `on_app_startup` | App starts up | None | ❌ No | + +## Creating a Plugin + +### 1. Create a Python file + +```bash +cd notediscovery/plugins +touch my_plugin.py +``` + +### 2. Define your plugin class + +Every plugin must have a `Plugin` class with: +- `name` - Display name +- `version` - Version string +- `enabled` - Whether it's active (default: `True`) + +### 3. Implement event hooks + +Add methods for the events you want to handle. + +## Basic Example: Note Logger + +This simple plugin logs note activity to Docker logs (visible with `docker-compose logs -f`): + +```python +""" +Note Logger Plugin +Logs all note operations to Docker logs for monitoring +""" + +class Plugin: + def __init__(self): + self.name = "Note Logger" + self.version = "1.0.0" + self.enabled = True + + def on_note_save(self, note_path: str, content: str) -> str | None: + """Log when a note is saved""" + word_count = len(content.split()) + print(f"πŸ’Ύ Note saved: {note_path} ({word_count} words)") + return None # Don't modify content, just observe + + def on_note_delete(self, note_path: str): + """Log when a note is deleted""" + print(f"πŸ—‘οΈ Note deleted: {note_path}") + + def on_search(self, query: str, results: list): + """Log search queries""" + print(f"πŸ” Search: '{query}' β†’ {len(results)} results") +``` + +### How to see the logs + +```bash +# View logs in real-time +docker-compose logs -f + +# View logs for specific service +docker-compose logs -f notediscovery +``` + +## Activating Your Plugin + +1. **Place the file** in `plugins/` directory +2. **Restart the app**: `docker-compose restart` +3. **Plugin auto-loads**: Plugins with `enabled = True` will automatically load + +### Enable/Disable Plugins via API + +Use the API to toggle plugins on/off: + +**Linux/Mac:** +```bash +# Enable a plugin +curl -X POST http://localhost:8000/api/plugins/note_logger/toggle \ + -H "Content-Type: application/json" \ + -d '{"enabled": true}' + +# Disable a plugin +curl -X POST http://localhost:8000/api/plugins/note_logger/toggle \ + -H "Content-Type: application/json" \ + -d '{"enabled": false}' +``` + +**Windows PowerShell:** +```powershell +# Enable a plugin +curl.exe -X POST http://localhost:8000/api/plugins/note_logger/toggle -H "Content-Type: application/json" -d "{\"enabled\": true}" + +# Disable a plugin +curl.exe -X POST http://localhost:8000/api/plugins/note_logger/toggle -H "Content-Type: application/json" -d "{\"enabled\": false}" +``` + +**List all plugins (all platforms):** +```bash +curl http://localhost:8000/api/plugins +``` + +## Plugin State Persistence + +Plugin states (enabled/disabled) are saved in `plugins/plugin_config.json` and persist between restarts. + +--- + +πŸ’‘ **Tip:** Use `print()` statements in plugins to log to Docker logs for debugging and monitoring! + diff --git a/data/notes/PLUGINS/Note Statistics.md b/data/notes/PLUGINS/Note Statistics.md new file mode 100644 index 0000000..1633012 --- /dev/null +++ b/data/notes/PLUGINS/Note Statistics.md @@ -0,0 +1,203 @@ +# πŸ“Š Note Statistics Plugin + +**Version:** 1.0.0 +**Status:** Enabled by default +**File:** `plugins/note_stats.py` + +--- + +## What It Does + +Calculates and displays comprehensive statistics for every note, including word count, reading time, character count, line count, links, images, code blocks, tasks, and more. Stats appear in the bottom of the UI. + +--- + +## Requirements + +**No additional dependencies required.** Uses built-in Python libraries only. + +--- + +## Installation + +**Already enabled by default!** No configuration needed. + +### To Disable/Enable via API + +**Disable:** +```bash +curl -X POST http://localhost:8000/api/plugins/note_stats/toggle \ + -H "Content-Type: application/json" \ + -d '{"enabled": false}' +``` + +**Enable:** +```bash +curl -X POST http://localhost:8000/api/plugins/note_stats/toggle \ + -H "Content-Type: application/json" \ + -d '{"enabled": true}' +``` + +--- + +## Statistics Provided + +### πŸ“ Content Metrics +- **Words** - Total word count +- **Characters** - With and without spaces +- **Lines** - Total lines in the note +- **Paragraphs** - Number of paragraphs +- **Sentences** - Estimated sentence count + +### ⏱️ Reading Time +- Calculated at 200 words per minute +- Displayed in minutes + +### πŸ”— Links & References +- **Total Links** - All `[text](url)` links +- **Internal Links** - `[[WikiLinks]]` to other notes +- **External Links** - HTTP/HTTPS URLs +- **Images** - `![alt](image.png)` count + +### πŸ’» Code Blocks +- **Inline Code** - `` `code` `` count +- **Code Blocks** - ` ```language ` ` blocks +- **Languages Used** - List of detected languages + +### βœ… Tasks +- **Total Tasks** - All `- [ ]` and `- [x]` items +- **Completed** - `- [x]` checked tasks +- **Pending** - `- [ ]` unchecked tasks +- **Progress** - Percentage complete + +### πŸ“‘ Structure +- **Headings** - H1, H2, H3 counts +- **Lists** - Bullet and numbered lists +- **Blockquotes** - `>` quote blocks +- **Tables** - Markdown table count + +--- + +## How It Works + +### Client-Side Calculation +Statistics are calculated **in real-time in your browser** as you type, providing instant feedback without server roundtrips. + +### On Save (Hook: `on_note_save`) +When a note is saved, the plugin: +1. Parses the markdown content +2. Calculates all statistics +3. Logs key metrics to Docker logs (for monitoring) + +### Hooks Used +- `on_note_save` - Logs stats to Docker after save + +--- + +## Viewing Statistics + +### In the UI +1. Open any note +2. Look at the **bottom** of the screen +3. Click to expand/collapse the stats panel +4. Statistics update in real-time as you type + +### In Docker Logs +```bash +docker-compose logs -f | grep "πŸ“Š" +``` + +Example output: +``` +πŸ“Š projects/website.md: + 1,234 words | 6m read | 89 lines + 15 links (5 internal) + 8/12 tasks completed +``` + +--- + +## Configuration + +No configuration needed. The plugin works out of the box. + +### Customization (Optional) + +To change reading speed calculation, edit `note_stats.py`: + +```python +# Line 36 +words_per_minute = 200 # Change to your reading speed +``` + +--- + +## Architecture + +### Client-Side (Frontend) +- Statistics are calculated in JavaScript (`app.js`) +- Updates in real-time as you type +- No API calls required for stats display +- Instant feedback, zero latency + +### Server-Side (Backend) +- Plugin logs stats to Docker when notes are saved +- Uses `on_note_save` hook +- Provides monitoring and tracking +- No API endpoints needed + +--- + +## Use Cases + +### πŸ“ Writing Goals +Track word count to meet daily writing targets. + +### ⏱️ Content Planning +Estimate reading time for blog posts or documentation. + +### βœ… Task Management +Monitor task completion progress across notes. + +### πŸ“Š Content Analysis +Understand structure and complexity of notes. + +### πŸ”— Link Auditing +Find orphaned notes or track reference density. + +--- + +## Troubleshooting + +**Issue:** Stats not showing in UI +- **Check:** Is plugin enabled? `curl http://localhost:8000/api/plugins` +- **Check:** Hard refresh browser (Ctrl+Shift+R) +- **Fix:** Save the note again to recalculate stats + +**Issue:** Wrong statistics +- **Cause:** Complex markdown formatting +- **Fix:** Plugin uses regex parsing - very complex markdown may vary +- **Note:** Statistics are estimates, not exact counts + +**Issue:** Performance on large notes +- **Note:** Plugin is optimized for notes <100KB +- **For large files:** Stats calculation may take a moment on first save + +--- + +## Technical Details + +### Calculation Methods + +**Words:** Split on whitespace, excluding code blocks +**Reading Time:** `words / 200 minutes` +**Links:** Regex match `[text](url)` and `[[WikiLink]]` +**Tasks:** Match `- [ ]` and `- [x]` +**Code Blocks:** Match ` ```language ` ` fences +**Headings:** Match `#`, `##`, `###` at line start + +### Performance +- **Client-side calculation** - Zero latency, no server roundtrips +- **Real-time updates** - As you type, stats refresh instantly +- **Lightweight** - Typical calculation: <10ms +- **No caching needed** - Calculated on-demand in browser \ No newline at end of file diff --git a/data/notes/THEMES.md b/data/notes/THEMES.md new file mode 100644 index 0000000..e7a7c3c --- /dev/null +++ b/data/notes/THEMES.md @@ -0,0 +1,130 @@ +# 🎨 Themes & Customization + +## Built-in Themes + +NoteDiscovery comes with **8 beautiful themes** out of the box: + +- 🌞 **Light** - Clean, professional, easy on the eyes +- πŸŒ™ **Dark** - Modern dark mode for night owls +- πŸ§› **Dracula** - Popular purple-tinted dark theme +- ❄️ **Nord** - Cool, Arctic-inspired color palette +- 🎨 **Monokai** - Vibrant, high-contrast theme inspired by the classic code editor theme +- πŸ’š **Vue High Contrast** - Dark theme with distinctive greenish tint and Vue.js aesthetics +- 🌊 **Cobalt2** - Deep ocean blue with vibrant yellow highlights, inspired by Wes Bos +- πŸ”· **VS Blue** - Classic Visual Studio 2015 light blue professional theme + +Switch themes anytime from the sidebar dropdown. Your preference is saved automatically! + +## Create Custom Themes + +### Step-by-Step Guide + +#### 1. Create a CSS file in the themes directory + +The file name will become the theme ID (use lowercase with hyphens): + +```bash +cd notediscovery/themes +touch my-awesome-theme.css +``` + +#### 2. Define the theme CSS variables + +**⚠️ IMPORTANT**: The `data-theme` attribute **MUST match** your filename (without `.css`). + +If your file is named `my-awesome-theme.css`, use `data-theme="my-awesome-theme"`: + +```css +/* My Awesome Theme - A beautiful custom theme */ +/* Description of your theme */ + +:root[data-theme="my-awesome-theme"] { + /* Background colors */ + --bg-primary: #ffffff; /* Main background */ + --bg-secondary: #f6f6f6; /* Sidebar/secondary areas */ + --bg-tertiary: #eeeeee; /* Tertiary backgrounds */ + --bg-hover: #e5e5e5; /* Hover state */ + --bg-active: #d4d4d4; /* Active/pressed state */ + + /* Text colors */ + --text-primary: #1a1a1a; /* Main text */ + --text-secondary: #4a4a4a; /* Secondary text */ + --text-tertiary: #6b6b6b; /* Muted/tertiary text */ + + /* Border colors */ + --border-primary: #d1d5db; /* Main borders */ + --border-secondary: #e5e7eb; /* Subtle borders */ + + /* Accent colors */ + --accent-primary: #3b82f6; /* Links, buttons, highlights */ + --accent-hover: #2563eb; /* Accent hover state */ + --accent-light: rgba(59, 130, 246, 0.1); /* Accent background */ + + /* Status colors */ + --success: #10b981; /* Success messages */ + --error: #ef4444; /* Error messages */ + --warning: #f59e0b; /* Warning messages */ + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.15); +} +``` + +#### 3. (Optional) Add a custom emoji icon + +Edit `backend/themes.py` and add your theme to the `theme_icons` dictionary: + +```python +theme_icons = { + # ... existing themes ... + "my-awesome-theme": "πŸš€" # Your custom emoji +} +``` + +If you skip this step, your theme will use 🎨 as the default icon. + +#### 4. Restart the application + +```bash +# If using Docker: +docker-compose restart + +# If running locally: +# Stop the server (Ctrl+C) and run again: +python -m notediscovery.backend.main +``` + +Your new theme will appear in the dropdown as **"πŸš€ My Awesome Theme"**! + +--- + +## Theme Development Tips + +### βœ… Required Variables +All these CSS variables **must** be defined for your theme to work properly: +- Background: `bg-primary`, `bg-secondary`, `bg-tertiary`, `bg-hover`, `bg-active` +- Text: `text-primary`, `text-secondary`, `text-tertiary` +- Borders: `border-primary`, `border-secondary` +- Accent: `accent-primary`, `accent-hover`, `accent-light` +- Status: `success`, `error`, `warning` +- Shadows: `shadow-sm`, `shadow-md`, `shadow-lg` + +### πŸ“‹ Quick Start +1. Copy an existing theme file (e.g., `dracula.css`) +2. Rename it to your theme name +3. Update the `data-theme` attribute to match +4. Modify the colors +5. Restart the app + +### πŸ” Testing +- Use browser DevTools to experiment with colors live +- Test with both light and dark system preferences +- Check contrast ratios for accessibility (use [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)) +- View different content types: code blocks, tables, links, etc. + +--- + +🎨 **Tip:** Use browser DevTools to experiment with colors in real-time before creating your theme! + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8e16a56 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + notediscovery: + build: . + container_name: notediscovery + ports: + - "8000:8000" + volumes: + # Persist notes and data (inside 'notes' folder) + - ./data:/app/data + # Custom plugins + - ./plugins:/app/plugins + # Custom themes + - ./themes:/app/themes + # Custom config (optional) + - ./config.yaml:/app/config.yaml + restart: unless-stopped + environment: + - TZ=UTC + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 60s + timeout: 3s + retries: 3 + start_period: 5s + diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..70cd4cb --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,1930 @@ +// NoteDiscovery Frontend Application + +// Configuration constants +const CONFIG = { + AUTOSAVE_DELAY: 1000, // ms - Delay before triggering autosave + 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) +}; + +// 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) { + alert(`Failed to ${operation}. Please try again.`); + } + } +}; + +function noteApp() { + return { + // App state + appName: 'NoteDiscovery', + appTagline: 'Your Self-Hosted Knowledge Base', + notes: [], + currentNote: '', + currentNoteName: '', + noteContent: '', + viewMode: 'split', // 'edit', 'split', 'preview' + searchQuery: '', + 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, + saveTimeout: null, + + // Theme state + currentTheme: 'light', + availableThemes: [], + + // Folder state + folderTree: [], + allFolders: [], + expandedFolders: new Set(), + draggedNote: null, + draggedFolder: null, + + // Scroll sync state + isScrolling: false, + + // Drag state for internal linking + draggedNoteForLink: null, + + // Undo/Redo history + undoHistory: [], + redoHistory: [], + maxHistorySize: CONFIG.MAX_UNDO_HISTORY, + isUndoRedo: false, + + // Stats plugin state + statsPluginEnabled: false, + noteStats: null, + statsExpanded: false, + + // Sidebar resize state + sidebarWidth: CONFIG.DEFAULT_SIDEBAR_WIDTH, + isResizing: false, + + // DOM element cache (to avoid repeated querySelector calls) + _domCache: { + editor: null, + previewContainer: null, + previewContent: null + }, + + // Initialize app + async init() { + await this.loadConfig(); + await this.loadThemes(); + await this.initTheme(); + await this.loadNotes(); + await this.checkStatsPlugin(); + this.loadSidebarWidth(); + this.loadViewMode(); + + // Parse URL and load specific note if provided + this.loadNoteFromURL(); + + // Listen for browser back/forward navigation + window.addEventListener('popstate', (e) => { + if (e.state && e.state.notePath) { + 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(); + } + } + }); + + // Cache DOM references after initial render + this.$nextTick(() => { + this.refreshDOMCache(); + }); + + // 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); + }); + } + }); + + // Setup keyboard shortcuts (only once to prevent double triggers) + if (!window.__noteapp_shortcuts_initialized) { + window.__noteapp_shortcuts_initialized = true; + window.addEventListener('keydown', (e) => { + // Ctrl/Cmd + S to save + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + this.saveNote(); + } + + // Ctrl/Cmd + Alt + N for new note + if ((e.ctrlKey || e.metaKey) && e.altKey && e.key === 'n') { + e.preventDefault(); + this.createNewNote(); + } + + // Ctrl/Cmd + Z for undo + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') { + e.preventDefault(); + this.undo(); + } + + // Ctrl/Cmd + Y or Ctrl/Cmd + Shift + Z for redo + if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { + e.preventDefault(); + this.redo(); + } + + // F3 for next search match + if (e.key === 'F3' && !e.shiftKey) { + e.preventDefault(); + this.nextMatch(); + } + + // Shift + F3 for previous search match + if (e.key === 'F3' && e.shiftKey) { + e.preventDefault(); + this.previousMatch(); + } + }); + } + + // 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'); + } + }); + } + }, + + // Load app configuration + async loadConfig() { + try { + const response = await fetch('/api/config'); + const config = await response.json(); + this.appName = config.name; + this.appTagline = config.tagline; + } 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); + }, + + // 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'; + } + } + } catch (error) { + console.error('Failed to load theme:', error); + } + }, + + // 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.buildFolderTree(); + } catch (error) { + ErrorHandler.handle('load notes', error); + } + }, + + // 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 notes to their folders + 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); + } + }); + + // Force Alpine reactivity by creating a new object reference + this.folderTree = { ...tree }; + }, + + // Toggle folder expansion + toggleFolder(folderPath) { + if (this.expandedFolders.has(folderPath)) { + this.expandedFolders.delete(folderPath); + } else { + this.expandedFolders.add(folderPath); + } + }, + + // Check if folder is expanded + isFolderExpanded(folderPath) { + return this.expandedFolders.has(folderPath); + }, + + // Expand all folders + expandAllFolders() { + // Add all folder paths to expandedFolders + this.allFolders.forEach(folder => { + this.expandedFolders.add(folder); + }); + // Force Alpine reactivity by creating new object reference (no rebuild needed) + this.folderTree = { ...this.folderTree }; + }, + + // Collapse all folders + collapseAllFolders() { + this.expandedFolders.clear(); + // Force Alpine reactivity by creating new object reference (no rebuild needed) + this.folderTree = { ...this.folderTree }; + }, + + // Expand folder tree to show a specific note + expandFolderForNote(notePath) { + // Extract folder path from note path + // e.g., "folder1/folder2/note.md" -> ["folder1", "folder1/folder2"] + 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 by creating new object reference + // This ensures the UI updates to show the expanded folders + this.folderTree = { ...this.folderTree }; + + // Also force a re-evaluation by modifying the Set (create new Set) + const oldFolders = this.expandedFolders; + this.expandedFolders = new Set(oldFolders); + }, + + // 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('[class*="px-3 py-2 mb-1"]'); + 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 + }, + + // Drag and drop handlers + onNoteDragStart(notePath, event) { + // Check if Ctrl/Cmd is held for link mode + if (event.ctrlKey || event.metaKey) { + // Link mode: drag to create internal link + this.draggedNoteForLink = notePath; + event.dataTransfer.effectAllowed = 'link'; + } else { + // Move mode: drag to move note + this.draggedNote = notePath; + event.dataTransfer.effectAllowed = 'move'; + } + }, + + onNoteDragEnd() { + this.draggedNote = null; + this.draggedNoteForLink = null; + }, + + // Handle dragover on editor to show cursor position + onEditorDragOver(event) { + if (!this.draggedNoteForLink) return; + + // Update cursor position as user drags over text + const textarea = event.target; + const textLength = textarea.value.length; + + // Calculate approximate cursor position based on mouse position + // This gives a rough idea of where the link will be inserted + textarea.focus(); + + // Try to set cursor at click position (works in most browsers) + if (textarea.setSelectionRange && document.caretPositionFromPoint) { + const pos = document.caretPositionFromPoint(event.clientX, event.clientY); + if (pos && pos.offsetNode === textarea) { + textarea.setSelectionRange(pos.offset, pos.offset); + } + } + }, + + // Handle dragenter on editor + onEditorDragEnter(event) { + if (!this.draggedNoteForLink) return; + event.preventDefault(); + }, + + // Handle dragleave on editor + onEditorDragLeave(event) { + // Note: draggedNoteForLink will be cleared on dragend anyway + }, + + // Handle drop into editor to create internal link + onEditorDrop(event) { + event.preventDefault(); + + if (!this.draggedNoteForLink) return; + + const notePath = this.draggedNoteForLink; + const noteName = notePath.split('/').pop().replace('.md', ''); + + // Create markdown link + const link = `[${noteName}](${notePath})`; + + // Insert at cursor position + const textarea = event.target; + const 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.draggedNoteForLink = null; + }, + + // 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 + if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//') || href.startsWith('mailto:')) { + return; // Let external links work normally + } + + // Prevent default navigation for internal links + event.preventDefault(); + + // Remove any anchor from the href (e.g., "note.md#section" -> "note.md") + const notePath = href.split('#')[0]; + + // Skip if it's just an anchor link + if (!notePath) return; + + // Find the note by path + const note = this.notes.find(n => n.path === notePath); + if (note) { + this.loadNote(notePath); + } else { + // Try to find by name (in case link uses just the note name) + const noteByName = this.notes.find(n => n.name === notePath || n.name === notePath + '.md'); + if (noteByName) { + this.loadNote(noteByName.path); + } else { + alert(`Note not found: ${notePath}`); + } + } + }, + + // Folder drag handlers + onFolderDragStart(folderPath) { + this.draggedFolder = folderPath; + }, + + onFolderDragEnd() { + this.draggedFolder = null; + }, + + async onFolderDrop(targetFolderPath) { + // Handle note drop into folder + if (this.draggedNote) { + const note = this.notes.find(n => n.path === this.draggedNote); + if (!note) return; + + // Get note filename + const filename = note.path.split('/').pop(); + const newPath = targetFolderPath ? `${targetFolderPath}/${filename}` : filename; + + if (newPath === this.draggedNote) { + this.draggedNote = null; + return; + } + + try { + const response = await fetch('/api/notes/move', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + oldPath: this.draggedNote, + newPath: newPath + }) + }); + + if (response.ok) { + await this.loadNotes(); + // Keep current note open if it was the moved note + if (this.currentNote === this.draggedNote) { + this.currentNote = newPath; + } + } else { + alert('Failed to move note.'); + } + } catch (error) { + console.error('Failed to move note:', error); + alert('Failed to move note.'); + } + + this.draggedNote = null; + return; + } + + // Handle folder drop into folder + if (this.draggedFolder) { + // Prevent dropping folder into itself or its subfolders + if (targetFolderPath === this.draggedFolder || + targetFolderPath.startsWith(this.draggedFolder + '/')) { + alert('Cannot move folder into itself or its subfolder.'); + this.draggedFolder = null; + return; + } + + const folderName = this.draggedFolder.split('/').pop(); + const newPath = targetFolderPath ? `${targetFolderPath}/${folderName}` : folderName; + + if (newPath === this.draggedFolder) { + this.draggedFolder = null; + return; + } + + try { + const response = await fetch('/api/folders/move', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + oldPath: this.draggedFolder, + newPath: newPath + }) + }); + + if (response.ok) { + await this.loadNotes(); + // Update current note path if it was in the moved folder + if (this.currentNote && this.currentNote.startsWith(this.draggedFolder + '/')) { + this.currentNote = this.currentNote.replace(this.draggedFolder, newPath); + } + } else { + alert('Failed to move folder.'); + } + } catch (error) { + console.error('Failed to move folder:', error); + alert('Failed to move folder.'); + } + + this.draggedFolder = null; + } + }, + + // Load a specific note + async loadNote(notePath, updateHistory = true, searchQuery = '') { + try { + 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({}, '', '/'); + this.currentNote = ''; + this.noteContent = ''; + return; + } + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + this.currentNote = notePath; + this.noteContent = data.content; + this.currentNoteName = notePath.split('/').pop().replace('.md', ''); + this.lastSaved = false; + + // Initialize undo/redo history for this note + this.undoHistory = [data.content]; + this.redoHistory = []; + + // 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 }, + '', + url + ); + } + + // Calculate stats if plugin enabled + if (this.statsPluginEnabled) { + this.calculateStats(); + } + + // 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 note from URL path + loadNoteFromURL() { + // Get path from URL (e.g., /folder/note or /note) + const path = window.location.pathname; + + // Skip if root path or static assets + if (path === '/' || path.startsWith('/static/') || path.startsWith('/api/')) { + return; + } + + // Remove leading slash, decode URL encoding (e.g., %20 -> space), and add .md extension + const decodedPath = decodeURIComponent(path.substring(1)); + 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); + mark.style.padding = '2px 4px'; + mark.style.borderRadius = '3px'; + mark.style.transition = 'all 0.2s'; + + // Style first match as active, others as inactive + if (matchIndex === 0) { + mark.style.backgroundColor = 'var(--accent-primary)'; + mark.style.color = 'white'; + mark.classList.add('active-match'); + } else { + mark.style.backgroundColor = 'rgba(255, 193, 7, 0.4)'; + mark.style.color = 'var(--text-primary)'; + } + + 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 + allMatches.forEach((mark, i) => { + if (i === index) { + mark.style.backgroundColor = 'var(--accent-primary)'; + mark.style.color = 'white'; + mark.classList.add('active-match'); + } else { + mark.style.backgroundColor = 'rgba(255, 193, 7, 0.4)'; + mark.style.color = 'var(--text-primary)'; + mark.classList.remove('active-match'); + } + }); + + // 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; + }, + + // Create a new note + async createNewNote() { + const noteName = prompt('Enter note name (you can use folder/name):'); + if (!noteName) return; + + const sanitizedName = noteName.trim().replace(/[^a-zA-Z0-9-_\s\/]/g, ''); + if (!sanitizedName) { + alert('Invalid note name.'); + return; + } + + // Add .md extension if not present + const notePath = sanitizedName.endsWith('.md') ? sanitizedName : `${sanitizedName}.md`; + + try { + const response = await fetch(`/api/notes/${notePath}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: '' }) + }); + + if (response.ok) { + await this.loadNotes(); + await this.loadNote(notePath); + } else { + ErrorHandler.handle('create note', new Error('Server returned error')); + } + } catch (error) { + ErrorHandler.handle('create note', error); + } + }, + + // Create a new folder with context + async createNewFolder(parentPath = '') { + const prompt_text = parentPath + ? `Create subfolder in "${parentPath}".\nEnter folder name:` + : 'Create new folder.\nEnter folder path (e.g., "Projects" or "Work/2025"):'; + + const folderName = prompt(prompt_text); + if (!folderName) return; + + const sanitizedName = folderName.trim().replace(/[^a-zA-Z0-9-_\s\/]/g, ''); + if (!sanitizedName) { + alert('Invalid folder name.'); + return; + } + + // Construct full path + const folderPath = parentPath ? `${parentPath}/${sanitizedName}` : sanitizedName; + + try { + const response = await fetch('/api/folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: folderPath }) + }); + + if (response.ok) { + // Automatically expand parent folder + if (parentPath) { + this.expandedFolders.add(parentPath); + } + await this.loadNotes(); + } 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(`Rename folder "${currentName}" to:`, currentName); + if (!newName || newName === currentName) return; + + const sanitizedName = newName.trim().replace(/[^a-zA-Z0-9-_\s]/g, ''); + if (!sanitizedName) { + alert('Invalid folder name.'); + return; + } + + // Calculate new path + const pathParts = folderPath.split('/'); + pathParts[pathParts.length - 1] = sanitizedName; + 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 current note path if it's in the renamed folder + if (this.currentNote && this.currentNote.startsWith(folderPath + '/')) { + this.currentNote = this.currentNote.replace(folderPath, newPath); + } + + 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( + `⚠️ WARNING ⚠️\n\n` + + `Are you sure you want to delete the folder "${folderName}"?\n\n` + + `This will PERMANENTLY delete:\n` + + `β€’ All notes inside this folder\n` + + `β€’ All subfolders and their contents\n\n` + + `This action CANNOT be undone!` + ); + + 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); + + // Clear current note if it was in the deleted folder + if (this.currentNote && this.currentNote.startsWith(folderPath + '/')) { + this.currentNote = ''; + this.noteContent = ''; + } + + await this.loadNotes(); + } else { + ErrorHandler.handle('delete folder', new Error('Server returned error')); + } + } catch (error) { + ErrorHandler.handle('delete folder', error); + } + }, + + // Create note in specific folder + async createNoteInFolder(folderPath) { + const noteName = prompt(`Create note in "${folderPath}".\nEnter note name:`); + if (!noteName) return; + + const sanitizedName = noteName.trim().replace(/[^a-zA-Z0-9-_\s]/g, ''); + if (!sanitizedName) { + alert('Invalid note name.'); + return; + } + + const notePath = `${folderPath}/${sanitizedName}.md`; + + try { + const response = await fetch(`/api/notes/${notePath}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: '' }) + }); + + if (response.ok) { + this.expandedFolders.add(folderPath); + await this.loadNotes(); + await this.loadNote(notePath); + } else { + alert('Failed to create note.'); + } + } catch (error) { + console.error('Failed to create note:', error); + alert('Failed to create note.'); + } + }, + + // 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(); + } + + this.saveTimeout = setTimeout(() => { + this.saveNote(); + }, CONFIG.AUTOSAVE_DELAY); + }, + + // Push current content to undo history + pushToHistory() { + // Only push if content actually changed + if (this.undoHistory.length > 0 && + this.undoHistory[this.undoHistory.length - 1] === this.noteContent) { + return; + } + + this.undoHistory.push(this.noteContent); + + // Limit history size + if (this.undoHistory.length > this.maxHistorySize) { + this.undoHistory.shift(); + } + + // Clear redo history when new change is made + this.redoHistory = []; + }, + + // Undo last change + undo() { + if (!this.currentNote || this.undoHistory.length <= 1) return; + + // Pop current state to redo history + const currentContent = this.undoHistory.pop(); + this.redoHistory.push(currentContent); + + // Get previous state + const previousContent = this.undoHistory[this.undoHistory.length - 1]; + + // Apply previous state + this.isUndoRedo = true; + this.noteContent = previousContent; + + // Recalculate stats with new content + if (this.statsPluginEnabled) { + this.calculateStats(); + } + + // Save the undone state + this.$nextTick(() => { + this.saveNote(); + this.isUndoRedo = false; + }); + }, + + // Redo last undone change + redo() { + if (!this.currentNote || this.redoHistory.length === 0) return; + + // Pop from redo history + const nextContent = this.redoHistory.pop(); + + // Push to undo history + this.undoHistory.push(nextContent); + + // Apply next state + this.isUndoRedo = true; + this.noteContent = nextContent; + + // Recalculate stats with new content + if (this.statsPluginEnabled) { + this.calculateStats(); + } + + // Save the redone state + this.$nextTick(() => { + this.saveNote(); + this.isUndoRedo = false; + }); + }, + + // 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(); + } + + // 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('Note name cannot be empty.'); + return; + } + + const folder = oldPath.split('/').slice(0, -1).join('/'); + const newPath = folder ? `${folder}/${newName}.md` : `${newName}.md`; + + if (oldPath === newPath) 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' }); + + 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; + + if (!confirm(`Delete "${this.currentNoteName}"?`)) return; + + try { + const response = await fetch(`/api/notes/${this.currentNote}`, { + method: 'DELETE' + }); + + if (response.ok) { + this.currentNote = ''; + this.noteContent = ''; + this.currentNoteName = ''; + await this.loadNotes(); + } else { + ErrorHandler.handle('delete note', new Error('Server returned error')); + } + } catch (error) { + ErrorHandler.handle('delete note', error); + } + }, + + // Delete any note from sidebar + async deleteNote(notePath, noteName) { + if (!confirm(`Delete "${noteName}"?`)) return; + + try { + const response = await fetch(`/api/notes/${notePath}`, { + method: 'DELETE' + }); + + if (response.ok) { + // If the deleted note is currently open, clear it + if (this.currentNote === notePath) { + this.currentNote = ''; + this.noteContent = ''; + this.currentNoteName = ''; + // 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 + async searchNotes() { + if (!this.searchQuery.trim()) { + this.searchResults = []; + this.currentSearchHighlight = ''; + this.clearSearchHighlights(); + return; + } + + try { + const response = await fetch(`/api/search?q=${encodeURIComponent(this.searchQuery)}`); + const data = await response.json(); + this.searchResults = data.results; + + // If a note is currently open, highlight the search term in real-time + if (this.currentNote && this.noteContent) { + this.currentSearchHighlight = this.searchQuery; + this.$nextTick(() => { + // Don't focus editor during real-time search (false) + this.highlightSearchTerm(this.searchQuery, false); + }); + } + } catch (error) { + console.error('Search failed:', error); + } + }, + + // Computed property for rendered markdown + get renderedMarkdown() { + if (!this.noteContent) return '

Nothing to preview yet...

'; + + // 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; + } + }); + + // Convert wiki-style links [[link]] to HTML links + let content = this.noteContent.replace(/\[\[([^\]]+)\]\]/g, (match, linkText) => { + return `[[${linkText}]]`; + }); + + // Parse markdown + let html = marked.parse(content); + + // Post-process: Add target="_blank" to external links + // 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'); + } + } + }); + + html = tempDiv.innerHTML; + + // 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) { + previewEl.querySelectorAll('pre code').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); + } + }); + } + }, 0); + + 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) { + // Create copy button + const button = document.createElement('button'); + button.className = 'copy-code-button'; + button.innerHTML = ` + + + + + `; + button.title = 'Copy to clipboard'; + + // Style the button + button.style.position = 'absolute'; + button.style.top = '8px'; + button.style.right = '8px'; + button.style.padding = '6px'; + 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'; + + // 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; + + try { + await navigator.clipboard.writeText(code); + + // Visual feedback - change icon to checkmark + button.innerHTML = ` + + + + `; + button.style.backgroundColor = 'rgba(34, 197, 94, 0.8)'; + button.title = 'Copied!'; + + // Reset after 2 seconds + setTimeout(() => { + button.innerHTML = ` + + + + + `; + button.style.backgroundColor = 'rgba(0, 0, 0, 0.6)'; + button.title = 'Copy to clipboard'; + }, 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 = ` + + + + `; + button.style.backgroundColor = 'rgba(34, 197, 94, 0.8)'; + setTimeout(() => { + button.innerHTML = ` + + + + + `; + 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; + + // Link count + const linkMatches = content.match(/\[([^\]]+)\]\(([^\)]+)\)/g) || []; + const links = linkMatches.length; + const internalLinks = linkMatches.filter(l => l.includes('.md')).length; + + // 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, + characters: chars, + total_characters: totalChars, + reading_time_minutes: readingTime, + lines, + paragraphs, + links, + internal_links: internalLinks, + external_links: links - internalLinks, + 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 + }; + }, + + // Load sidebar width from localStorage + loadSidebarWidth() { + const saved = localStorage.getItem('sidebarWidth'); + if (saved) { + const width = parseInt(saved, 10); + if (width >= 200 && width <= 600) { + this.sidebarWidth = width; + } + } + }, + + // Save sidebar width to localStorage + saveSidebarWidth() { + localStorage.setItem('sidebarWidth', this.sidebarWidth.toString()); + }, + + // Load view mode from localStorage + loadViewMode() { + try { + const saved = localStorage.getItem('viewMode'); + if (saved && ['edit', 'split', 'preview'].includes(saved)) { + this.viewMode = saved; + } + } catch (error) { + console.error('Error loading view mode:', error); + } + }, + + // Save view mode to localStorage + saveViewMode() { + try { + localStorage.setItem('viewMode', this.viewMode); + } catch (error) { + console.error('Error saving view mode:', 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); + }, + + // 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); + } + } +} + diff --git a/frontend/favicon.svg b/frontend/favicon.svg new file mode 100644 index 0000000..6d1f0e9 --- /dev/null +++ b/frontend/favicon.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..094bfbb --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,1232 @@ + + + + + + NoteDiscovery + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+ NoteDiscovery Logo +

+
+ + +
+ + +
+

+
+ + +
+
+ + + + + + + +
+ + +
+ +
+ + +
+
+
+ + +
+ + +
+ + +
+ + + +
+ + +
+
+ notes +
+
+
+ + +
+ + +
+ πŸ”— Link Mode - Drop in editor to create link + πŸ“¦ Move Mode - Drop on folder to move +
+ + +
+ + + + + +
+
+ + + + + diff --git a/frontend/logo.svg b/frontend/logo.svg new file mode 100644 index 0000000..cd1fc0b --- /dev/null +++ b/frontend/logo.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/note_stats.py b/plugins/note_stats.py new file mode 100644 index 0000000..bd2fb28 --- /dev/null +++ b/plugins/note_stats.py @@ -0,0 +1,164 @@ +""" +Note Statistics Plugin for NoteDiscovery +Calculates and logs statistics about your notes + +Shows: +- Word count +- Character count +- Reading time estimate +- Number of links +- Number of code blocks +- Line count +""" + +import re + + +class Plugin: + def __init__(self): + self.name = "Note Statistics" + self.version = "1.0.0" + self.enabled = True + self.stats_history = {} + + def calculate_stats(self, content: str) -> dict: + """Calculate comprehensive note statistics""" + # Word count (split by whitespace and filter empty) + words = len([w for w in re.findall(r'\S+', content) if w]) + + # Character count (excluding whitespace) + chars = len(re.sub(r'\s', '', content)) + + # Total character count (including whitespace) + total_chars = len(content) + + # Reading time (average 200 words per minute) + reading_time = max(1, round(words / 200)) + + # Line count + lines = len(content.split('\n')) + + # Paragraph count (blocks separated by blank lines) + paragraphs = len([p for p in content.split('\n\n') if p.strip()]) + + # Markdown link count + links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+)\)', content)) + + # Internal link count (links to .md files) + internal_links = len(re.findall(r'\[([^\]]+)\]\(([^\)]+\.md)\)', content)) + + # Code block count + code_blocks = len(re.findall(r'```[\s\S]*?```', content)) + + # Inline code count + inline_code = len(re.findall(r'`[^`]+`', content)) + + # Heading count by level + h1_count = len(re.findall(r'^# ', content, re.MULTILINE)) + h2_count = len(re.findall(r'^## ', content, re.MULTILINE)) + h3_count = len(re.findall(r'^### ', content, re.MULTILINE)) + + # Task count (checkboxes) + total_tasks = len(re.findall(r'- \[[ x]\]', content)) + completed_tasks = len(re.findall(r'- \[x\]', content, re.IGNORECASE)) + pending_tasks = total_tasks - completed_tasks + + # Image count + images = len(re.findall(r'!\[([^\]]*)\]\(([^\)]+)\)', content)) + + # Blockquote count + blockquotes = len(re.findall(r'^> ', content, re.MULTILINE)) + + return { + 'words': words, + 'characters': chars, + 'total_characters': total_chars, + 'reading_time_minutes': reading_time, + 'lines': lines, + 'paragraphs': paragraphs, + 'links': links, + 'internal_links': internal_links, + 'external_links': links - internal_links, + 'code_blocks': code_blocks, + 'inline_code': inline_code, + 'headings': { + 'h1': h1_count, + 'h2': h2_count, + 'h3': h3_count, + 'total': h1_count + h2_count + h3_count + }, + 'tasks': { + 'total': total_tasks, + 'completed': completed_tasks, + 'pending': pending_tasks, + 'completion_rate': round(completed_tasks / total_tasks * 100) if total_tasks > 0 else 0 + }, + 'images': images, + 'blockquotes': blockquotes + } + + def format_stats(self, stats: dict) -> str: + """Format statistics as a readable string""" + lines = [ + f"πŸ“Š Statistics:", + f" Words: {stats['words']:,}", + f" Reading time: ~{stats['reading_time_minutes']} min", + f" Lines: {stats['lines']:,}", + ] + + if stats['links'] > 0: + lines.append(f" Links: {stats['links']} ({stats['internal_links']} internal, {stats['external_links']} external)") + + if stats['code_blocks'] > 0: + lines.append(f" Code blocks: {stats['code_blocks']}") + + if stats['tasks']['total'] > 0: + lines.append(f" Tasks: {stats['tasks']['completed']}/{stats['tasks']['total']} completed ({stats['tasks']['completion_rate']}%)") + + if stats['headings']['total'] > 0: + lines.append(f" Headings: {stats['headings']['total']} (H1: {stats['headings']['h1']}, H2: {stats['headings']['h2']}, H3: {stats['headings']['h3']})") + + return '\n'.join(lines) + + def on_note_save(self, note_path: str, content: str) -> str | None: + """Calculate and log statistics when note is saved""" + stats = self.calculate_stats(content) + + # Store stats history + self.stats_history[note_path] = stats + + # Log key statistics + print(f"πŸ“Š {note_path}:") + print(f" {stats['words']:,} words | {stats['reading_time_minutes']}m read | {stats['lines']:,} lines") + + if stats['links'] > 0: + print(f" {stats['links']} links ({stats['internal_links']} internal)") + + if stats['tasks']['total'] > 0: + print(f" {stats['tasks']['completed']}/{stats['tasks']['total']} tasks completed") + + return None # Don't modify content, just observe + + def get_stats(self, note_path: str) -> dict: + """Get cached statistics for a note""" + return self.stats_history.get(note_path, {}) + + def get_total_stats(self) -> dict: + """Get aggregated statistics across all notes""" + if not self.stats_history: + return {} + + total_words = sum(s['words'] for s in self.stats_history.values()) + total_notes = len(self.stats_history) + total_links = sum(s['links'] for s in self.stats_history.values()) + total_tasks = sum(s['tasks']['total'] for s in self.stats_history.values()) + + return { + 'total_notes': total_notes, + 'total_words': total_words, + 'average_words_per_note': round(total_words / total_notes) if total_notes > 0 else 0, + 'total_links': total_links, + 'total_tasks': total_tasks, + 'total_reading_time': sum(s['reading_time_minutes'] for s in self.stats_history.values()) + } + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..56d3204 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +python-multipart==0.0.6 +markdown==3.5.1 +pyyaml==6.0.1 +aiofiles==23.2.1 +cryptography==41.0.7 + diff --git a/run.py b/run.py new file mode 100644 index 0000000..bacf027 --- /dev/null +++ b/run.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +""" +Quick start script for NoteDiscovery +Run this to start the application without Docker +""" + +import sys +import subprocess +from pathlib import Path + +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/notes").mkdir(parents=True, exist_ok=True) + Path("data/search_index").mkdir(parents=True, exist_ok=True) + Path("plugins").mkdir(parents=True, exist_ok=True) + + print("βœ“ Dependencies installed") + print("βœ“ Directories created") + print("\n" + "="*50) + print("πŸŽ‰ NoteDiscovery is running!") + print("="*50) + print("\nπŸ“ Open your browser to: http://localhost:8000") + print("\nπŸ’‘ Tips:") + print(" - Press Ctrl+C to stop the server") + print(" - Your notes are in ./data/notes/") + print(" - Plugins go in ./plugins/") + print("\n" + "="*50 + "\n") + + # Run the application + subprocess.call([ + sys.executable, "-m", "uvicorn", + "backend.main:app", + "--reload", + "--host", "0.0.0.0", + "--port", "8000" + ]) + +if __name__ == "__main__": + main() + diff --git a/themes/cobalt2.css b/themes/cobalt2.css new file mode 100644 index 0000000..ac9f54e --- /dev/null +++ b/themes/cobalt2.css @@ -0,0 +1,36 @@ +/* Cobalt2 Theme */ +/* Inspired by Wes Bos's Cobalt2 theme for VS Code */ + +:root[data-theme="cobalt2"] { + /* Background colors */ + --bg-primary: #193549; + --bg-secondary: #122738; + --bg-tertiary: #1f4662; + --bg-hover: #234e6d; + --bg-active: #275472; + + /* Text colors */ + --text-primary: #ffffff; + --text-secondary: #e8e8e8; + --text-tertiary: #aaaaaa; + + /* Border colors */ + --border-primary: #0d3a58; + --border-secondary: #0a2942; + + /* Accent colors - Cobalt blue/yellow */ + --accent-primary: #ffc600; + --accent-hover: #ffdd57; + --accent-light: rgba(255, 198, 0, 0.15); + + /* Status colors */ + --success: #3ad900; + --error: #ff0088; + --warning: #ffc600; + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.6); +} + diff --git a/themes/dark.css b/themes/dark.css new file mode 100644 index 0000000..dae25ae --- /dev/null +++ b/themes/dark.css @@ -0,0 +1,34 @@ +/* Dark Theme */ +:root[data-theme="dark"] { + /* Background colors */ + --bg-primary: #1f2937; + --bg-secondary: #111827; + --bg-tertiary: #374151; + --bg-hover: #374151; + --bg-active: #4b5563; + + /* Text colors */ + --text-primary: #f9fafb; + --text-secondary: #d1d5db; + --text-tertiary: #9ca3af; + + /* Border colors */ + --border-primary: #374151; + --border-secondary: #4b5563; + + /* Accent colors */ + --accent-primary: #60a5fa; + --accent-hover: #3b82f6; + --accent-light: #1e3a8a; + + /* Status colors */ + --success: #34d399; + --error: #f87171; + --warning: #fbbf24; + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5); +} + diff --git a/themes/dracula.css b/themes/dracula.css new file mode 100644 index 0000000..078032c --- /dev/null +++ b/themes/dracula.css @@ -0,0 +1,36 @@ +/* Dracula Theme - Inspired by Dracula Color Scheme */ +/* https://draculatheme.com/ */ + +:root[data-theme="dracula"] { + /* Background colors */ + --bg-primary: #282a36; + --bg-secondary: #21222c; + --bg-tertiary: #343746; + --bg-hover: #343746; + --bg-active: #44475a; + + /* Text colors */ + --text-primary: #f8f8f2; + --text-secondary: #e6e6e6; + --text-tertiary: #6272a4; + + /* Border colors */ + --border-primary: #44475a; + --border-secondary: #3a3c4e; + + /* Accent colors */ + --accent-primary: #bd93f9; + --accent-hover: #9f7aea; + --accent-light: rgba(189, 147, 249, 0.15); + + /* Status colors */ + --success: #50fa7b; + --error: #ff5555; + --warning: #f1fa8c; + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5); +} + diff --git a/themes/light.css b/themes/light.css new file mode 100644 index 0000000..a7d19c7 --- /dev/null +++ b/themes/light.css @@ -0,0 +1,34 @@ +/* Light Theme - Default */ +:root[data-theme="light"] { + /* Background colors */ + --bg-primary: #ffffff; + --bg-secondary: #f9fafb; + --bg-tertiary: #f3f4f6; + --bg-hover: #f3f4f6; + --bg-active: #e5e7eb; + + /* Text colors */ + --text-primary: #111827; + --text-secondary: #6b7280; + --text-tertiary: #9ca3af; + + /* Border colors */ + --border-primary: #e5e7eb; + --border-secondary: #d1d5db; + + /* Accent colors */ + --accent-primary: #3b82f6; + --accent-hover: #2563eb; + --accent-light: #dbeafe; + + /* Status colors */ + --success: #10b981; + --error: #ef4444; + --warning: #f59e0b; + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); +} + diff --git a/themes/monokai.css b/themes/monokai.css new file mode 100644 index 0000000..3368727 --- /dev/null +++ b/themes/monokai.css @@ -0,0 +1,36 @@ +/* Monokai Theme - Classic code editor theme */ +/* Inspired by Sublime Text's Monokai */ + +:root[data-theme="monokai"] { + /* Background colors */ + --bg-primary: #272822; + --bg-secondary: #1e1f1c; + --bg-tertiary: #3e3d32; + --bg-hover: #3e3d32; + --bg-active: #49483e; + + /* Text colors */ + --text-primary: #f8f8f2; + --text-secondary: #cfcfc2; + --text-tertiary: #75715e; + + /* Border colors */ + --border-primary: #49483e; + --border-secondary: #3e3d32; + + /* Accent colors */ + --accent-primary: #66d9ef; + --accent-hover: #a6e22e; + --accent-light: rgba(102, 217, 239, 0.15); + + /* Status colors */ + --success: #a6e22e; + --error: #f92672; + --warning: #e6db74; + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5); +} + diff --git a/themes/nord.css b/themes/nord.css new file mode 100644 index 0000000..3a446df --- /dev/null +++ b/themes/nord.css @@ -0,0 +1,36 @@ +/* Nord Theme - Arctic, north-bluish color palette */ +/* https://www.nordtheme.com/ */ + +:root[data-theme="nord"] { + /* Background colors */ + --bg-primary: #2e3440; + --bg-secondary: #3b4252; + --bg-tertiary: #434c5e; + --bg-hover: #434c5e; + --bg-active: #4c566a; + + /* Text colors */ + --text-primary: #eceff4; + --text-secondary: #d8dee9; + --text-tertiary: #81a1c1; + + /* Border colors */ + --border-primary: #4c566a; + --border-secondary: #434c5e; + + /* Accent colors */ + --accent-primary: #88c0d0; + --accent-hover: #5e81ac; + --accent-light: rgba(136, 192, 208, 0.15); + + /* Status colors */ + --success: #a3be8c; + --error: #bf616a; + --warning: #ebcb8b; + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5); +} + diff --git a/themes/vs-blue.css b/themes/vs-blue.css new file mode 100644 index 0000000..62b45ee --- /dev/null +++ b/themes/vs-blue.css @@ -0,0 +1,36 @@ +/* Visual Studio Blue Theme */ +/* Inspired by classic Visual Studio 2012-2015 Light Blue theme */ + +:root[data-theme="vs-blue"] { + /* Background colors */ + --bg-primary: #ffffff; + --bg-secondary: #f6f6f6; + --bg-tertiary: #eeeef2; + --bg-hover: #e7e9f0; + --bg-active: #cce4f7; + + /* Text colors */ + --text-primary: #1e1e1e; + --text-secondary: #424242; + --text-tertiary: #717171; + + /* Border colors */ + --border-primary: #cccedb; + --border-secondary: #e5e5e5; + + /* Accent colors - VS Blue */ + --accent-primary: #007acc; + --accent-hover: #005a9e; + --accent-light: rgba(0, 122, 204, 0.1); + + /* Status colors */ + --success: #388a34; + --error: #e51400; + --warning: #f6a623; + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.08); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.12); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.16); +} + diff --git a/themes/vue-high-contrast.css b/themes/vue-high-contrast.css new file mode 100644 index 0000000..544527f --- /dev/null +++ b/themes/vue-high-contrast.css @@ -0,0 +1,36 @@ +/* Vue High Contrast Theme */ +/* Inspired by Vue Theme High Contrast - Dark with greenish tint */ + +:root[data-theme="vue-high-contrast"] { + /* Background colors - dark with green tint */ + --bg-primary: #0a1e0d; + --bg-secondary: #0d2912; + --bg-tertiary: #143d1a; + --bg-hover: #1a4d23; + --bg-active: #235c2b; + + /* Text colors - bright for high contrast */ + --text-primary: #e8f5e9; + --text-secondary: #c8e6c9; + --text-tertiary: #81c784; + + /* Border colors - subtle green tint */ + --border-primary: #2e7d32; + --border-secondary: #1b5e20; + + /* Accent colors - Vue signature green */ + --accent-primary: #42b883; + --accent-hover: #4fc896; + --accent-light: rgba(66, 184, 131, 0.2); + + /* Status colors */ + --success: #4caf50; + --error: #ff5252; + --warning: #ffb74d; + + /* Shadows - deeper for high contrast */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.6); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.7); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.8); +} +