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
261 lines
9.6 KiB
Python
261 lines
9.6 KiB
Python
"""
|
|
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)
|