Initial commit

This commit is contained in:
Gamosoft 2025-11-05 17:48:41 +01:00
commit 0afa382df9
32 changed files with 6110 additions and 0 deletions

20
.dockerignore Normal file
View File

@ -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

53
.gitignore vendored Normal file
View File

@ -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

34
Dockerfile Normal file
View File

@ -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"]

22
LICENSE Normal file
View File

@ -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.

143
README.md Normal file
View File

@ -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

2
backend/__init__.py Normal file
View File

@ -0,0 +1,2 @@
# NoteDiscovery Backend

607
backend/main.py Normal file
View File

@ -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']
)

274
backend/plugins.py Normal file
View File

@ -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', '')

63
backend/themes.py Normal file
View File

@ -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()

299
backend/utils.py Normal file
View File

@ -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
}

25
config.yaml Normal file
View File

@ -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

252
data/notes/API.md Normal file
View File

@ -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!

107
data/notes/FEATURES.md Normal file
View File

@ -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.

125
data/notes/PLUGINS.md Normal file
View File

@ -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!

View File

@ -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

130
data/notes/THEMES.md Normal file
View File

@ -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!

25
docker-compose.yml Normal file
View File

@ -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

1930
frontend/app.js Normal file

File diff suppressed because it is too large Load Diff

31
frontend/favicon.svg Normal file
View File

@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<!-- Background circle with gradient -->
<defs>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Main circle background -->
<circle cx="50" cy="50" r="48" fill="url(#bgGradient)"/>
<!-- Document/Note icon -->
<g transform="translate(50, 50)">
<!-- Paper -->
<rect x="-18" y="-22" width="36" height="44" rx="2" fill="white" opacity="0.95"/>
<!-- Lines representing text -->
<line x1="-12" y1="-14" x2="12" y2="-14" stroke="#667eea" stroke-width="2" stroke-linecap="round"/>
<line x1="-12" y1="-6" x2="12" y2="-6" stroke="#667eea" stroke-width="2" stroke-linecap="round"/>
<line x1="-12" y1="2" x2="8" y2="2" stroke="#667eea" stroke-width="2" stroke-linecap="round"/>
<!-- Pen/pencil icon overlay -->
<g transform="translate(8, 8) rotate(-45)">
<rect x="0" y="-1.5" width="12" height="3" fill="#f59e0b" rx="0.5"/>
<polygon points="12,1.5 14,0 12,-1.5" fill="#92400e"/>
<circle cx="3" cy="0" r="1" fill="#fbbf24"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

1232
frontend/index.html Normal file

File diff suppressed because it is too large Load Diff

27
frontend/logo.svg Normal file
View File

@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<defs>
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Main document shape -->
<rect x="40" y="30" width="120" height="140" rx="8" fill="url(#logoGradient)"/>
<!-- Paper fold effect at top right -->
<path d="M 140 30 L 140 50 L 160 50 Z" fill="#5568d3"/>
<!-- Lines representing text on the document -->
<line x1="60" y1="70" x2="140" y2="70" stroke="white" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<line x1="60" y1="90" x2="140" y2="90" stroke="white" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<line x1="60" y1="110" x2="120" y2="110" stroke="white" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<!-- Discovery symbol - a magnifying glass -->
<g transform="translate(100, 135)">
<circle cx="0" cy="0" r="18" fill="none" stroke="white" stroke-width="4"/>
<line x1="13" y1="13" x2="25" y2="25" stroke="white" stroke-width="4" stroke-linecap="round"/>
<circle cx="0" cy="0" r="10" fill="white" opacity="0.3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

164
plugins/note_stats.py Normal file
View File

@ -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())
}

8
requirements.txt Normal file
View File

@ -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

50
run.py Normal file
View File

@ -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()

36
themes/cobalt2.css Normal file
View File

@ -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);
}

34
themes/dark.css Normal file
View File

@ -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);
}

36
themes/dracula.css Normal file
View File

@ -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);
}

34
themes/light.css Normal file
View File

@ -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);
}

36
themes/monokai.css Normal file
View File

@ -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);
}

36
themes/nord.css Normal file
View File

@ -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);
}

36
themes/vs-blue.css Normal file
View File

@ -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);
}

View File

@ -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);
}