fix(watch): the owner's folder choice must never be overwritten

Two bugs found while filing the Obsidian Sorter note itself:

1. proposed_toc went stale when proposed_folder was edited by hand.
   The watcher now re-resolves the index line from the folder on every pass.

2. Re-proposing after a body edit clobbered a hand-edited proposed_folder.
   The watcher's own suggestion is now kept separately in
   proposed_folder_auto, so a difference marks a human choice, which wins.

Adds two guard tests. 17/17 passing.
This commit is contained in:
sam
2026-09-26 06:57:41 +10:00
parent 2c7c8257d1
commit 92ea55f77d
2 changed files with 60 additions and 13 deletions

View File

@@ -34,13 +34,13 @@ from .vault import Vault
from .writer import FIELD_ORDER, Writer from .writer import FIELD_ORDER, Writer
# Fields the watcher owns. Never present on a filed note. # Fields the watcher owns. Never present on a filed note.
ADMIN_FIELDS = ("proposed_folder", "proposed_toc", "proposed_tags", ADMIN_FIELDS = ("proposed_folder", "proposed_folder_auto", "proposed_toc",
"confidence", "approved", "proposed_for") "proposed_tags", "confidence", "approved", "proposed_for")
PROPOSAL_ORDER = ( PROPOSAL_ORDER = (
"title", "type", "status", "title", "type", "status",
"proposed_folder", "proposed_toc", "proposed_tags", "confidence", "proposed_folder", "proposed_folder_auto", "proposed_toc", "proposed_tags",
"approved", "proposed_for", "confidence", "approved", "proposed_for",
"created", "updated", "tags", "aliases", "id", "created", "updated", "tags", "aliases", "id",
) )
@@ -79,6 +79,21 @@ def proposal_is_stale(note: Note) -> bool:
return recorded != content_hash(note) 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: class Watcher:
"""Polls the inbox and maintains proposals.""" """Polls the inbox and maintains proposals."""
@@ -117,13 +132,17 @@ class Watcher:
# ----------------------------------------------------------------- proposing # ----------------------------------------------------------------- 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.""" """Add the proposal fields to the note in place. Returns log lines."""
fm = dict(note.fm) 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. # Resolve the real index + section so the proposal reads clearly.
toc_rel, toc_section, _ = self.writer.resolve_toc( 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: if toc_rel and toc_section:
fm["proposed_toc"] = f"{toc_rel} → {toc_section}" fm["proposed_toc"] = f"{toc_rel} → {toc_section}"
@@ -132,7 +151,8 @@ class Watcher:
else: else:
fm["proposed_toc"] = "(not indexed)" 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: if proposal.proposed_tags:
fm["proposed_tags"] = proposal.proposed_tags fm["proposed_tags"] = proposal.proposed_tags
fm["confidence"] = round(proposal.confidence, 2) fm["confidence"] = round(proposal.confidence, 2)
@@ -143,10 +163,11 @@ class Watcher:
if not self.dry_run: if not self.dry_run:
self.writer._atomic_write(Path(note.path), text) self.writer._atomic_write(Path(note.path), text)
return [ note_msg = f"**proposed** `{note.filename}` → `{effective}` "
f"**proposed** `{note.filename}` → `{proposal.target_folder}` " if preserve_folder:
f"(confidence {proposal.confidence:.2f})", note_msg += "(your folder kept) "
] + [f" - {w}" for w in proposal.warnings] note_msg += f"(confidence {proposal.confidence:.2f})"
return [note_msg] + [f" - {w}" for w in proposal.warnings]
def _expected_toc(self, folder: str) -> str: def _expected_toc(self, folder: str) -> str:
"""The index + section a folder should be listed under.""" """The index + section a folder should be listed under."""
@@ -242,10 +263,13 @@ class Watcher:
f"**held** `{note.filename}` — " f"**held** `{note.filename}` — "
+ "; ".join(proposal.warnings) + "; ".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") actions.append(f"**held** `{note.filename}` — no destination determined")
else: 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 except Exception as e: # never let one note kill the loop
actions.append(f"**error** `{note.filename}`: {type(e).__name__}: {e}") actions.append(f"**error** `{note.filename}`: {type(e).__name__}: {e}")
return actions return actions

View File

@@ -326,6 +326,29 @@ def main() -> int:
except OSError: except OSError:
pass 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 -------------------------------------------------- # ---- inbox cleared --------------------------------------------------
left = [p for p in (vault / args.inbox).glob("*.md") left = [p for p in (vault / args.inbox).glob("*.md")
if not p.name.startswith("_")] if not p.name.startswith("_")]