diff --git a/vault_sync_daemon.py b/vault_sync_daemon.py index 995d902..73f5bac 100644 --- a/vault_sync_daemon.py +++ b/vault_sync_daemon.py @@ -19,10 +19,10 @@ FORWARD (/vault -> /data): REVERSE (/data -> /vault): - a new folder appears in /data whose basename matches the `SYS.AC.ID ` Johnny Decimal pattern, with NO /vault counterpart yet (e.g. dropped in - over SMB) -> auto-create a minimal /vault stub folder + folder-note, - tagged "needs review", AND append a `#fix-me` line to the nearest `.01` - Inbox note in /vault (category-level first, then area/system, falling - back to the top-level System inbox). + over SMB) -> auto-create a minimal /vault stub folder + folder-note + with just `aliases: []` frontmatter (no + "needs review" banner, no inbox #fix-me flagging — confirmed with + Ryan 2026-09-22: keep it quiet, just the empty note + alias). - Folders inside any `.09`-pattern archive folder are never auto-stubbed (archives are meant to hold retired stuff, not grow new tracked IDs) — this also breaks the feedback loop from the forward-delete archive move. @@ -92,7 +92,17 @@ ID_RE = vl.ID_RE CAT_RE = vl.CAT_RE ARCHIVE_ID_RE = re.compile(r'^([RJFI])\.(\d\d)\.09 ') # e.g. "F.25.09 Archive" ARCHIVE_CAT_RE = re.compile(r'^([RJFI])\.(\d\d) ') # category folder, to find/create .09 inside -INBOX_ID_RE = re.compile(r'^([RJFI])\.(\d\d)\.01 ') + +# Obsidian's "New folder" action does `mkdir Untitled` (or "Untitled 1", "2", +# etc. if that name's taken) the INSTANT you click it, before you've typed a +# real name -- it is never a real OS rename() afterward, just a second +# mkdir-with-real-name once you hit enter (confirmed with Ryan 2026-09-22). +# The "Untitled" placeholder is always transient and must never be mirrored +# to /data: doing so left permanent orphan junk there (the real delete event +# for "Untitled" gets silently dropped by handle_vault_deleted, which only +# tracks ID-shaped names) and produced two separate "create" ntfy pings +# instead of the one the user actually cares about. +UNTITLED_RE = re.compile(r'^Untitled(\s\d+)?$') # Journal entries: /vault/R Ryan/.../R.16.11 My journal & notes by day/YYYY/MM/YYYY-MM-DD.md # gets an auto-injected `title: MMM D, YYYY` frontmatter property (matches the @@ -210,6 +220,12 @@ def handle_vault_created(path): rel = os.path.relpath(path, VAULT) if rel.split(os.sep)[0] == vl.SYSTEM_META: return # "0 System area" never mirrors to /data, by design + if UNTITLED_RE.match(os.path.basename(path)): + # Obsidian's transient "New folder" placeholder -- never mirror it. + # The real create (with the name Ryan actually typed) fires as its + # own separate event moments later and mirrors normally, so skipping + # this one is enough: no orphan junk in /data, no duplicate ntfy. + return data_path = os.path.join(DATA, rel) if os.path.isdir(data_path): return # already mirrored (duplicate event or pre-existing) @@ -234,7 +250,25 @@ def handle_vault_moved(src_path, dest_path): data_src = os.path.join(DATA, rel_src) data_dest = os.path.join(DATA, rel_dest) if not os.path.isdir(data_src): - return # nothing to mirror + # No /data counterpart to rename -- most commonly because the + # source was an "Untitled" placeholder we deliberately never + # mirrored (see UNTITLED_RE). Don't just drop this silently: the + # folder (now under its real name) still needs to exist in /data, + # so fall through to a normal create-mirror at the destination. + if os.path.exists(data_dest): + return # already mirrored somehow, nothing to do + parent = os.path.dirname(data_dest) + if not os.path.isdir(parent): + log(f"SKIP move-as-create mirror (parent missing in /data): {rel_dest}") + return + os.makedirs(data_dest, exist_ok=True) + log(f"MIRRORED create /data (from untitled-rename): {rel_dest}") + ntfy_post( + "Vault sync: new folder", + f"Created in /vault, mirrored to /data: {rel_dest}", + tags="sparkles", + ) + return if os.path.exists(data_dest): log(f"SKIP move mirror (dest exists): {rel_src} -> {rel_dest}") return @@ -417,43 +451,10 @@ def handle_vault_deleted(path): # REVERSE: /data -> /vault # --------------------------------------------------------------------------- -def _find_nearest_inbox_note(rel_path): - """ - Inbox destination is ALWAYS the immediate owning category of the new - /data folder (same rule as archives: create the missing '.01 Inbox' - right there if it doesn't exist yet, never borrow one from a higher - ancestor). - """ - parts = rel_path.split(os.sep) - if len(parts) < 2: - return None - cat_rel = os.sep.join(parts[:-1]) - cat_vault = os.path.join(VAULT, cat_rel) - cat_data = os.path.join(DATA, cat_rel) - catname = os.path.basename(cat_vault) - m = CAT_RE.match(catname) - if not m or not os.path.isdir(cat_vault): - return None - sysletter, catnum = m.group(1), m.group(2) - for name in sorted(os.listdir(cat_vault)): - if INBOX_ID_RE.match(name): - note = os.path.join(cat_vault, name, name + ".md") - if os.path.exists(note): - return note - # missing — create it in THIS category, not a higher one - inbox_name = f"{sysletter}.{catnum}.01 Inbox for {_category_title(catname)}" - vpath = os.path.join(cat_vault, inbox_name) - note = os.path.join(vpath, inbox_name + ".md") - os.makedirs(vpath, exist_ok=True) - with open(note, "w", encoding="utf-8") as f: - f.write(f"# {inbox_name}\n\n" - f"Auto-created by vault_sync_daemon on " - f"{datetime.datetime.now().isoformat(timespec='seconds')} " - f"to flag an unrecognized /data folder in this category.\n") - log(f"CREATED category inbox slot: {os.path.relpath(vpath, VAULT)}") - dpath = os.path.join(cat_data, inbox_name) - os.makedirs(dpath, exist_ok=True) - return note +def _id_title(idname): + """'R.32.20 Recreational drugs' -> 'Recreational drugs' (ID stripped).""" + m = ID_RE.match(idname) + return idname[m.end():].strip() if m else idname def handle_data_created(path): @@ -480,27 +481,15 @@ def handle_data_created(path): return note = os.path.join(vault_path, base + ".md") os.makedirs(vault_path, exist_ok=True) - ts = datetime.datetime.now().isoformat(timespec="seconds") + alias = _id_title(base) with open(note, "w", encoding="utf-8") as f: - f.write(f"# {base}\n\n" - f"> [!warning] Needs review\n" - f"> Auto-created by vault_sync_daemon on {ts} because a matching " - f"folder appeared in `/data` with no `/vault` note yet.\n") + f.write(f"---\naliases:\n - {alias}\n---\n") log(f"STUBBED /vault note for new /data folder: {rel}") - commit_paths = [note] - inbox = _find_nearest_inbox_note(rel) - if inbox: - with open(inbox, "a", encoding="utf-8") as f: - f.write(f"\n- #fix-me New `/data` folder detected with no vault note: " - f"[[{base}]] (auto-stub created {ts})\n") - log(f"FLAGGED in inbox: {os.path.relpath(inbox, VAULT)}") - commit_paths.append(inbox) - _git_commit(f"vault_sync_daemon: stubbed {base}", commit_paths) + _git_commit(f"vault_sync_daemon: stubbed {base}", [note]) ntfy_post( - "Vault sync: needs review", - f"New /data folder with no vault note: {rel}\nStub created, flagged in nearest inbox.", - tags="package,warning", - priority="high", + "Vault sync: new note", + f"New /data folder with no vault note: {rel}\nAuto-created stub with alias \"{alias}\".", + tags="package,sparkles", )