Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,4 @@ video/

# Browser libraries, downloaded by scripts/vendor_assets.py
frontend/vendor/
CLAUDE.md
51 changes: 50 additions & 1 deletion documentation/PLUGINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Plugins are Python files that live in the `plugins/` directory. They use **event
| `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_search` | Search is performed | `query`, `results` | ⚠️ In place only ([see below](#advanced-example-open-tasks)) |
| `on_app_startup` | App starts up | None | ❌ No |

## Creating a Plugin
Expand Down Expand Up @@ -68,6 +68,55 @@ class Plugin:
print(f"🔍 Search: '{query}' → {len(results)} results")
```

## Advanced Example: Open Tasks

`plugins/open_tasks.py` (bundled) turns the search box into a task inbox. Search
for **`@task`** (or `@tasks`) and, instead of a normal full-text search, you get
every note that still has an unchecked checkbox:

```markdown
- [ ] Buy milk ← found
- [x] Bread ← ignored, already done
```

Each result shows the note plus up to three of its open tasks, and carries an
extra `open_tasks` field with the true count for API and MCP consumers.

**What counts as an open task:** a list item whose box is empty — `- [ ]`,
`* []`, `+ [ ]` or `1. [ ]`. `[x]`/`[X]` are done and never match. Checkboxes
inside fenced code blocks are skipped, as are hidden files and folders.

### The `on_search` trick

`on_search` is listed as non-modifying because **its return value is discarded**
— `PluginManager.run_hook()` only propagates returns for hooks that receive
`content`. But `results` is the *same list object* that `/api/search` paginates
afterwards, so replacing its contents in place does reach the response:

```python
TRIGGERS = {"@task", "@tasks"}

def on_search(self, query: str, results: list):
if query.strip().lower() not in TRIGGERS:
return # leave every other search alone
replacement = self._scan(...) # build the full list first
results[:] = replacement # in-place swap — `results.clear()` + extend works too
```

Build the replacement list **before** the swap. If the scan raises halfway
through, `results` is left untouched and the user still gets ordinary search
results (`run_hook` catches and logs the exception).

Two limits worth knowing:

- **You cannot control ordering.** `/api/search` always re-sorts by path after
the hook runs.
- **The normal search still runs first.** The hook fires after `search_notes()`,
so its work is discarded when you replace the results.

Because snippets are rendered as HTML in the sidebar, **escape any note content**
you put into a `context` field (`html.escape`), exactly as core search does.

### How to see the logs

```bash
Expand Down
203 changes: 203 additions & 0 deletions plugins/open_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
"""
Open Tasks Plugin for NoteDiscovery

Turns the search box into a task inbox: searching for "@task" (or "@tasks")
replaces the normal full-text results with every note that still has an
unchecked checkbox, e.g.

- [ ] Buy milk

How it works: `on_search` cannot return a new result set (PluginManager
discards the return value of void hooks), but `results` is the same list object
that /api/search paginates afterwards, so we replace its contents in place.

Ordering is not ours to pick — /api/search always re-sorts by path.
"""

import logging
import os
import re
from html import escape
from pathlib import Path
from typing import Dict, List, Tuple

logger = logging.getLogger("uvicorn.error")

# Search strings that switch the search into task mode (compared lowercased).
TRIGGERS = {"@task", "@tasks"}

# An unchecked GFM task item: "- [ ] label", "* [] label", "1. [ ] label".
# "[x]" / "[X]" deliberately do not match — those are done.
OPEN_TASK_RE = re.compile(r'^[ \t]*(?:[-*+]|\d+[.)])[ \t]+\[ ?\][ \t]*(.*)$')

# Snippets shown per note. Matches what search_notes() returns, and the sidebar
# only renders the first one anyway.
MAX_MATCHES_PER_NOTE = 1
MAX_LABEL_CHARS = 120
UNTITLED_TASK = "(untitled task)"

# Same markup search_notes() emits, so the theme highlights it identically.
# The label is escaped because the frontend renders context with x-html.
_MARKER = '<mark class="search-highlight">&#9744;</mark>'


def extract_open_tasks(content: str) -> Tuple[List[Dict], int]:
"""Return (snippets, total_open_count) for one note's content.

Fenced code blocks are skipped so a checkbox inside a ``` example is not
reported as a real task.
"""
matches: List[Dict] = []
count = 0
in_fence = False

for line_number, line in enumerate(content.split('\n'), start=1):
stripped = line.lstrip()
if stripped.startswith('```') or stripped.startswith('~~~'):
in_fence = not in_fence
continue
if in_fence:
continue

match = OPEN_TASK_RE.match(line)
if not match:
continue

count += 1
if len(matches) < MAX_MATCHES_PER_NOTE:
label = match.group(1).strip() or UNTITLED_TASK
if len(label) > MAX_LABEL_CHARS:
label = label[:MAX_LABEL_CHARS].rstrip() + '…'
matches.append({
"line_number": line_number,
"context": f'{_MARKER} {escape(label)}',
})

return matches, count


class Plugin:
def __init__(self):
self.name = "Open Tasks"
self.version = "1.0.0"
self.enabled = True
# full path -> (mtime, size, matches, open_count). Rebuilt every scan,
# which also evicts notes that were deleted or moved.
self._cache: Dict[str, Tuple[float, int, List[Dict], int]] = {}
self._notes_dir: Path | None = None

# ------------------------------------------------------------------
# Hook
# ------------------------------------------------------------------

def on_search(self, query: str, results: list):
"""Replace results with the open-task list when the query is @task."""
if query.strip().lower() not in TRIGGERS:
return

notes_dir = self._resolve_notes_dir()
if not notes_dir.is_dir():
logger.warning("open_tasks: notes directory not found: %s", notes_dir)
return

# Built fully before swapping, so a failure mid-scan leaves the original
# results untouched rather than half-replaced.
replacement = self._scan(notes_dir)
results[:] = replacement
logger.info(
"open_tasks: '%s' -> %d note(s) with %d open task(s)",
query, len(replacement), sum(r["open_tasks"] for r in replacement),
)

# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------

def _scan(self, notes_dir: Path) -> List[Dict]:
"""Walk the vault and collect notes that still have unchecked boxes.

Skips hidden files and folders, mirroring scan_notes_fast_walk().
"""
fresh: Dict[str, Tuple[float, int, List[Dict], int]] = {}
found: List[Dict] = []

for root, dirnames, filenames in os.walk(notes_dir):
dirnames[:] = [d for d in dirnames if not d.startswith('.')]
root_path = Path(root)

for filename in filenames:
if filename.startswith('.') or not filename.lower().endswith('.md'):
continue

full_path = root_path / filename
try:
st = full_path.stat()
except OSError:
continue

key = str(full_path)
cached = self._cache.get(key)
if cached and cached[0] == st.st_mtime and cached[1] == st.st_size:
matches, count = cached[2], cached[3]
else:
try:
with open(full_path, 'r', encoding='utf-8') as f:
content = f.read()
except (OSError, UnicodeDecodeError):
continue
matches, count = extract_open_tasks(content)

# Cached even at count 0, so task-free notes are read only once.
fresh[key] = (st.st_mtime, st.st_size, matches, count)

if count:
relative_path = full_path.relative_to(notes_dir)
parent = relative_path.parent.as_posix()
found.append({
"name": full_path.stem,
"path": relative_path.as_posix(),
"folder": "" if parent == "." else parent,
"matches": matches,
# Extra field: the true count even when snippets are capped.
# The web UI ignores it; API/MCP consumers can use it.
"open_tasks": count,
})

self._cache = fresh
return found

def _resolve_notes_dir(self) -> Path:
"""Resolve the vault the same way backend/main.py does.

NOTES_DIR env var > storage.notes_dir in config.yaml > ./data.
Memoized: config.yaml is not re-read at runtime by the app either.
"""
if self._notes_dir is not None:
return self._notes_dir

if 'NOTES_DIR' in os.environ:
self._notes_dir = Path(os.environ['NOTES_DIR'])
return self._notes_dir

# cwd is the app root under both `python run.py` and Docker (WORKDIR
# /app); the plugin-relative path is the fallback for an unusual layout.
candidates = [
Path.cwd() / "config.yaml",
Path(__file__).resolve().parent.parent / "config.yaml",
]
for config_path in candidates:
try:
if not config_path.is_file():
continue
import yaml
with open(config_path, 'r', encoding='utf-8') as f:
cfg = yaml.safe_load(f) or {}
notes_dir = (cfg.get('storage') or {}).get('notes_dir')
if notes_dir:
self._notes_dir = Path(notes_dir)
return self._notes_dir
except Exception as exc:
logger.warning("open_tasks: could not read %s: %s", config_path, exc)

self._notes_dir = Path("./data")
return self._notes_dir