Initial commit
This commit is contained in:
commit
0afa382df9
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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"]
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# NoteDiscovery Backend
|
||||
|
||||
|
|
@ -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']
|
||||
)
|
||||
|
||||
|
|
@ -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', '')
|
||||
|
||||
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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!
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
@ -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!
|
||||
|
||||
|
|
@ -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** - `` 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
|
||||
|
|
@ -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!
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 |
File diff suppressed because it is too large
Load Diff
|
|
@ -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 |
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue