diff --git a/sorter/watch.py b/sorter/watch.py index 81ec0b8..2e9be5a 100644 --- a/sorter/watch.py +++ b/sorter/watch.py @@ -34,13 +34,13 @@ 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") +ADMIN_FIELDS = ("proposed_folder", "proposed_folder_auto", "proposed_toc", + "proposed_tags", "confidence", "approved", "proposed_for") PROPOSAL_ORDER = ( "title", "type", "status", - "proposed_folder", "proposed_toc", "proposed_tags", "confidence", - "approved", "proposed_for", + "proposed_folder", "proposed_folder_auto", "proposed_toc", "proposed_tags", + "confidence", "approved", "proposed_for", "created", "updated", "tags", "aliases", "id", ) @@ -79,6 +79,21 @@ def proposal_is_stale(note: Note) -> bool: return recorded != content_hash(note) +def user_selected_folder(note: Note) -> str | None: + """ + The folder the owner chose by hand, if they changed it. + + We keep the watcher's own suggestion in `proposed_folder_auto`, so a + difference between the two means a human edited it. Their choice wins — + the watcher must never overwrite it. + """ + chosen = str(note.fm.get("proposed_folder") or "").strip() + auto = str(note.fm.get("proposed_folder_auto") or "").strip() + if chosen and auto and chosen != auto: + return chosen + return None + + class Watcher: """Polls the inbox and maintains proposals.""" @@ -117,13 +132,17 @@ class Watcher: # ----------------------------------------------------------------- proposing - def _write_proposal(self, note: Note, proposal: Proposal) -> list[str]: + def _write_proposal(self, note: Note, proposal: Proposal, + preserve_folder: str | None = None) -> list[str]: """Add the proposal fields to the note in place. Returns log lines.""" fm = dict(note.fm) + # The owner's hand-picked folder always wins over a fresh suggestion. + effective = preserve_folder or proposal.target_folder + # Resolve the real index + section so the proposal reads clearly. toc_rel, toc_section, _ = self.writer.resolve_toc( - proposal.note_type, proposal.target_folder + type_from_folder(effective) or proposal.note_type, effective ) if toc_rel and toc_section: fm["proposed_toc"] = f"{toc_rel} → {toc_section}" @@ -132,7 +151,8 @@ class Watcher: else: fm["proposed_toc"] = "(not indexed)" - fm["proposed_folder"] = proposal.target_folder + fm["proposed_folder"] = effective + fm["proposed_folder_auto"] = proposal.target_folder if proposal.proposed_tags: fm["proposed_tags"] = proposal.proposed_tags fm["confidence"] = round(proposal.confidence, 2) @@ -143,10 +163,11 @@ class Watcher: 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] + note_msg = f"**proposed** `{note.filename}` → `{effective}` " + if preserve_folder: + note_msg += "(your folder kept) " + note_msg += f"(confidence {proposal.confidence:.2f})" + return [note_msg] + [f" - {w}" for w in proposal.warnings] def _expected_toc(self, folder: str) -> str: """The index + section a folder should be listed under.""" @@ -242,10 +263,13 @@ class Watcher: f"**held** `{note.filename}` — " + "; ".join(proposal.warnings) ) - elif not proposal.target_folder: + elif not proposal.target_folder and not user_selected_folder(note): actions.append(f"**held** `{note.filename}` — no destination determined") else: - actions += self._write_proposal(note, proposal) + actions += self._write_proposal( + note, proposal, + preserve_folder=user_selected_folder(note), + ) except Exception as e: # never let one note kill the loop actions.append(f"**error** `{note.filename}`: {type(e).__name__}: {e}") return actions diff --git a/tests/run_tests.py b/tests/run_tests.py index 11245ca..26b478c 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -326,6 +326,29 @@ def main() -> int: except OSError: pass + # ---- watcher: the owner's hand-picked folder must survive ---------- + from sorter.watch import user_selected_folder + from sorter.models import Note as _Note + + def _n(chosen, auto): + return _Note(path="x", rel_path="010 inbox/x.md", filename="x.md", + stem="x", title="x", note_type="note", status=None, + fm={"proposed_folder": chosen, "proposed_folder_auto": auto}) + + if user_selected_folder(_n("300 areas/A", "200 projects/B")) == "300 areas/A": + print("PASS guard: a hand-edited folder is treated as the owner's choice") + passed += 1 + else: + details.append("FAIL guard: hand-edited folder not detected") + failed += 1 + + if user_selected_folder(_n("200 projects/B", "200 projects/B")) is None: + print("PASS guard: an untouched proposal is not treated as customised") + passed += 1 + else: + details.append("FAIL guard: untouched proposal wrongly seen as customised") + failed += 1 + # ---- inbox cleared -------------------------------------------------- left = [p for p in (vault / args.inbox).glob("*.md") if not p.name.startswith("_")]