obsidian-sorter: automated note filing for an Obsidian vault

Classifies notes from an inbox folder and files them, with a human
approval step. Zero third-party dependencies.

- type mirrors folder (200 projects / 300 areas / 400 Personal Family)
- TypeSafe JEV for semantic classification (~$0.0001 per note)
- watcher writes a proposal INTO the note; the owner ticks a checkbox
- systemd user service on .27, 10s poll
- 15/15 functional tests
This commit is contained in:
sam
2026-09-26 06:53:24 +10:00
commit 33ec9b5bc5
16 changed files with 3783 additions and 0 deletions

260
sorter/watch.py Normal file
View File

@@ -0,0 +1,260 @@
"""
The inbox watcher.
Implements Sam's review-by-frontmatter workflow. Nothing is ever filed without
an explicit approval, and the approval is a checkbox inside the note itself.
you save a note into 010 inbox/ type: note
|
v watcher classifies it
proposal written into the note approved: false [ ]
|
v you tick the checkbox in Obsidian
approved: true [x]
|
v watcher files it
note moves to the right folder, admin fields stripped, TOC updated
Admin fields (removed on filing):
proposed_folder, proposed_toc, proposed_tags, confidence, approved
"""
from __future__ import annotations
import hashlib
import os
import time
from datetime import datetime
from pathlib import Path
from typing import Iterable
from . import frontmatter
from .models import Note, Proposal, type_from_folder
from .vault import Vault
from .writer import FIELD_ORDER, Writer
# Fields the watcher owns. Never present on a filed note.
ADMIN_FIELDS = ("proposed_folder", "proposed_toc", "proposed_tags",
"confidence", "approved", "proposed_for")
PROPOSAL_ORDER = (
"title", "type", "status",
"proposed_folder", "proposed_toc", "proposed_tags", "confidence",
"approved", "proposed_for",
"created", "updated", "tags", "aliases", "id",
)
LOG_NAME = "_sort log.md"
# --------------------------------------------------------------------- helpers
def has_proposal(note: Note) -> bool:
return bool(str(note.fm.get("proposed_folder") or "").strip())
def is_approved(note: Note) -> bool:
value = note.fm.get("approved")
if isinstance(value, bool):
return value
return str(value).strip().lower() in ("true", "yes", "y", "1", "approved")
def is_planned(note: Note) -> bool:
"""Notes whose destination is set but that are not approved yet."""
return has_proposal(note) and not is_approved(note)
def content_hash(note: Note) -> str:
"""Fingerprint of the parts of a note a proposal depends on."""
basis = f"{note.title or note.stem}\n{note.body}"
return hashlib.sha1(basis.encode("utf-8")).hexdigest()[:12]
def proposal_is_stale(note: Note) -> bool:
"""True when the note changed after its proposal was written."""
recorded = str(note.fm.get("proposed_for") or "").strip()
if not recorded:
return True # proposals made before this feature existed
return recorded != content_hash(note)
class Watcher:
"""Polls the inbox and maintains proposals."""
def __init__(
self,
vault: Vault,
classifier,
inbox: str = "010 inbox",
interval: float = 5.0,
dry_run: bool = False,
):
self.vault = vault
self.classifier = classifier
self.writer = Writer(vault)
self.inbox = inbox
self.interval = interval
self.dry_run = dry_run
self.log_path = vault.root / inbox / LOG_NAME
# ------------------------------------------------------------------ logging
def _log(self, lines: Iterable[str]) -> None:
lines = [l for l in lines if l]
if not lines:
return
stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
block = f"\n### {stamp}\n" + "\n".join(f"- {l}" for l in lines) + "\n"
if self.dry_run:
return
if not self.log_path.exists():
self.log_path.parent.mkdir(parents=True, exist_ok=True)
self.log_path.write_text(
"# Sort log\n\nWhat the watcher did, newest last.\n", encoding="utf-8")
with open(self.log_path, "a", encoding="utf-8") as fh:
fh.write(block)
# ----------------------------------------------------------------- proposing
def _write_proposal(self, note: Note, proposal: Proposal) -> list[str]:
"""Add the proposal fields to the note in place. Returns log lines."""
fm = dict(note.fm)
# Resolve the real index + section so the proposal reads clearly.
toc_rel, toc_section, _ = self.writer.resolve_toc(
proposal.note_type, proposal.target_folder
)
if toc_rel and toc_section:
fm["proposed_toc"] = f"{toc_rel} → {toc_section}"
elif toc_rel:
fm["proposed_toc"] = toc_rel
else:
fm["proposed_toc"] = "(not indexed)"
fm["proposed_folder"] = proposal.target_folder
if proposal.proposed_tags:
fm["proposed_tags"] = proposal.proposed_tags
fm["confidence"] = round(proposal.confidence, 2)
fm["approved"] = False
fm["proposed_for"] = content_hash(note)
text = frontmatter.render(fm, note.body, order=PROPOSAL_ORDER)
if not self.dry_run:
self.writer._atomic_write(Path(note.path), text)
return [
f"**proposed** `{note.filename}` → `{proposal.target_folder}` "
f"(confidence {proposal.confidence:.2f})",
] + [f" - {w}" for w in proposal.warnings]
def _expected_toc(self, folder: str) -> str:
"""The index + section a folder should be listed under."""
note_type = type_from_folder(folder)
if not note_type:
return ""
toc_rel, toc_section, _ = self.writer.resolve_toc(note_type, folder)
if toc_rel and toc_section:
return f"{toc_rel} → {toc_section}"
return toc_rel or "(not indexed)"
def _sync_toc(self, note: Note) -> list[str]:
"""
Keep `proposed_toc` truthful.
If the folder is edited by hand, the index line must follow it —
otherwise the note shows a destination that no longer matches.
"""
folder = str(note.fm.get("proposed_folder") or "").strip()
expected = self._expected_toc(folder)
current = str(note.fm.get("proposed_toc") or "").strip()
if not expected or expected == current:
return []
fm = dict(note.fm)
fm["proposed_toc"] = expected
text = frontmatter.render(fm, note.body, order=PROPOSAL_ORDER)
if not self.dry_run:
self.writer._atomic_write(Path(note.path), text)
return [f"**toc follows folder** `{note.filename}` → {expected}"]
# ------------------------------------------------------------------- filing
def _file(self, note: Note) -> list[str]:
"""File an approved note. Returns log lines."""
folder = str(note.fm.get("proposed_folder") or "").strip()
if not type_from_folder(folder):
return [f"**skipped** `{note.filename}` — "
f"proposed_folder is not inside a known bucket: {folder!r}"]
tags = note.fm.get("proposed_tags")
if isinstance(tags, str):
tags = [t.strip() for t in tags.split(",") if t.strip()]
if not isinstance(tags, list):
tags = []
proposal = Proposal(
source_rel=note.rel_path,
note_type=type_from_folder(folder) or "area",
target_folder=folder,
toc_section=str(note.fm.get("proposed_toc") or ""),
template="Area.md",
confidence=1.0,
method="approved",
proposed_tags=[str(t) for t in tags],
)
plan = self.writer.plan(note, proposal)
if not plan.writable:
return [f"**blocked** `{note.filename}` — {plan.blocked_reason}"]
if self.dry_run:
return [f"**would file** `{note.filename}` → `{folder}`"]
ok, log = self.writer.apply(plan, source_path=note.path)
lines = [f"**filed** `{note.filename}` → `{plan.dest_rel}` (type {plan.note_type})"]
if not ok:
lines = [f"**FAILED** `{note.filename}`: " + "; ".join(log)]
return lines
# --------------------------------------------------------------------- loop
def run_once(self) -> list[str]:
"""One pass over the inbox. Returns human-readable log lines."""
actions: list[str] = []
for note in self.vault.inbox_notes(self.inbox):
try:
if is_approved(note):
actions += self._file(note)
elif is_planned(note) and not proposal_is_stale(note):
# Awaiting your click. Still verify the index line matches the
# folder, in case the folder was edited by hand.
actions += self._sync_toc(note)
continue
else:
if is_planned(note):
actions.append(
f"**re-proposing** `{note.filename}` — you edited it"
)
proposal = self.classifier.classify(note)
if proposal.method in ("blocked", "jev-error"):
actions.append(
f"**held** `{note.filename}` — "
+ "; ".join(proposal.warnings)
)
elif not proposal.target_folder:
actions.append(f"**held** `{note.filename}` — no destination determined")
else:
actions += self._write_proposal(note, proposal)
except Exception as e: # never let one note kill the loop
actions.append(f"**error** `{note.filename}`: {type(e).__name__}: {e}")
return actions
def run_forever(self, max_iterations: int | None = None) -> None:
n = 0
while max_iterations is None or n < max_iterations:
self._log(self.run_once())
n += 1
if max_iterations is not None and n >= max_iterations:
break
time.sleep(self.interval)