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:
260
sorter/watch.py
Normal file
260
sorter/watch.py
Normal 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)
|
||||
Reference in New Issue
Block a user