""" 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)