From bf9f01baaa1d734fe725fe607121624d9b2ffecc Mon Sep 17 00:00:00 2001 From: LuBeDa Date: Fri, 7 Aug 2026 11:38:14 +0200 Subject: [PATCH 1/2] search for open tasks --- .gitignore | 1 + documentation/PLUGINS.md | 51 +++++++++- plugins/open_tasks.py | 203 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 plugins/open_tasks.py diff --git a/.gitignore b/.gitignore index 015d0b85..451ef66d 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ video/ # Browser libraries, downloaded by scripts/vendor_assets.py frontend/vendor/ +CLAUDE.md diff --git a/documentation/PLUGINS.md b/documentation/PLUGINS.md index f21f0aaa..11071382 100644 --- a/documentation/PLUGINS.md +++ b/documentation/PLUGINS.md @@ -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 @@ -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 diff --git a/plugins/open_tasks.py b/plugins/open_tasks.py new file mode 100644 index 00000000..fb4fbaa0 --- /dev/null +++ b/plugins/open_tasks.py @@ -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 = 3 +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 = '' + + +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 From 925f81dba138b5204082f23b076f14189213abd5 Mon Sep 17 00:00:00 2001 From: LuBeDa Date: Fri, 7 Aug 2026 11:49:59 +0200 Subject: [PATCH 2/2] plugin to search for open tasks/checkboxes --- plugins/open_tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/open_tasks.py b/plugins/open_tasks.py index fb4fbaa0..82af4280 100644 --- a/plugins/open_tasks.py +++ b/plugins/open_tasks.py @@ -32,7 +32,7 @@ # Snippets shown per note. Matches what search_notes() returns, and the sidebar # only renders the first one anyway. -MAX_MATCHES_PER_NOTE = 3 +MAX_MATCHES_PER_NOTE = 1 MAX_LABEL_CHARS = 120 UNTITLED_TASK = "(untitled task)"