The loader only globs plugins/*.py, so plugins/contrib/ ships with the project without being loaded - users opt in by copying a file up one level. Each plugin documents itself in its docstring, so contributing one touches no shared file and can't conflict with another.
6.9 KiB
🔌 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 |
✅ Yes (return a replacement result list, or None) |
on_app_startup |
App starts up | None | ❌ No |
Every hook that can modify follows the same rule: return a new value to replace
the one you were given, or return None to leave it alone. Plugins run in
filename order, each receiving what the previous one left behind. A plugin that
raises is logged and skipped — the value keeps its last good state, so one broken
plugin can't take down a request.
Replacements must be the right type (str for content hooks, list for
on_search). Anything else is logged and ignored rather than passed on.
Search result ordering
Core search results are re-sorted by path before pagination, so that paging
through them stays reproducible while notes are being edited. If your on_search
returns its own list, that sort is skipped and your order is preserved — which
also makes the order your responsibility. Return results in a stable order
(due date, name, relevance score), not one that shifts between calls, or paginated
clients can see duplicates.
Bundled and Contributed Plugins
note_stats ships in plugins/ and loads at startup. Community plugins live in
plugins/contrib/, which the loader ignores — install one by copying it into
plugins/ and restarting. Each carries its own documentation in its docstring;
see plugins/contrib/README.md.
Creating a Plugin
1. Create a Python file
cd notediscovery/plugins
touch my_plugin.py
2. Define your plugin class
Every plugin must have a Plugin class with:
name- Display nameversion- Version stringenabled- Whether it's active (default:True)
3. Implement event hooks
Add methods for the events you want to handle. Method names are checked at load
time: an on_* method that isn't a known hook gets a warning in the log rather
than silently never running.
Plugin Context
If your plugin defines setup(ctx), it's called once at startup before any hook
fires. Use it instead of trying to work out where things live yourself — the
context always agrees with the running app, including NOTES_DIR overrides.
| Field | What it is |
|---|---|
ctx.notes_dir |
Path to the vault the app is actually serving |
ctx.plugins_dir |
Path to the plugins directory |
ctx.config |
The resolved app config dict |
ctx.logger |
Logger named for your plugin, output goes to the server log |
class Plugin:
def __init__(self):
self.name = "My Plugin"
self.version = "1.0.0"
self.enabled = True
def setup(self, ctx):
self.notes_dir = ctx.notes_dir
self.log = ctx.logger
self.log.info("ready, watching %s", self.notes_dir)
Plugin Routes
A plugin can serve its own HTTP endpoints by returning an APIRouter from
get_routes(). Routes are mounted under /api/plugins/<plugin_id>/ and inherit
the app's authentication.
from fastapi import APIRouter
class Plugin:
# ... name / version / enabled as above ...
def get_routes(self) -> APIRouter:
router = APIRouter()
@router.get("/summary")
async def summary():
if not self.enabled:
return {"enabled": False}
return {"enabled": True, "notes": 42}
return router
That endpoint is then reachable at /api/plugins/my_plugin/summary. Routes are
mounted at startup whether or not the plugin is enabled, so check self.enabled
inside the handler if it should go quiet when toggled off.
The bundled note_stats plugin uses both of these — see plugins/note_stats.py
for a working example.
Basic Example: Note Logger
This simple plugin logs note activity to Docker logs (visible with docker-compose logs -f):
"""
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) -> list | None:
"""Log search queries"""
print(f"🔍 Search: '{query}' → {len(results)} results")
return None # Don't touch the results, just observe
How to see the logs
# View logs in real-time
docker-compose logs -f
# View logs for specific service
docker-compose logs -f notediscovery
Activating Your Plugin
- Place the file in
plugins/directory - Restart the app:
docker-compose restart - Plugin auto-loads: Plugins with
enabled = Truewill automatically load
Enable/Disable Plugins via API
Use the API to toggle plugins on/off:
Linux/Mac:
# 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:
# 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):
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!