daemon: never mirror Obsidian's transient 'Untitled' placeholder; strip stub inbox/needs-review noise

Two fixes:

1. Untitled folder junk + phantom-rename bug (Ryan reported 2026-09-22):
   Obsidian's 'New folder' action does mkdir Untitled immediately, then
   creates the real-named folder afterward -- either as a genuine OS
   rename() or as a second separate mkdir + move-in, depending on
   Obsidian's internal path (confirmed both happen). Previously we
   mirrored 'Untitled' to /data like any other folder, so:
     - the two-separate-creates case left a permanent 'Untitled' orphan
       in /data (its delete event was silently dropped -- handle_vault_deleted
       only tracks ID-shaped names) and fired two separate 'new folder'
       ntfy pings instead of the one rename ping the user expected.
     - the real-rename case (once Untitled stopped being mirrored) then
       had no data_src to rename, so the folder never reached /data at all.

   Fix: UNTITLED_RE skips mirroring 'Untitled'/'Untitled N' outright in
   handle_vault_created. handle_vault_moved falls back to a create-mirror
   at the destination when there's no data_src to rename, covering the
   genuine-rename-from-Untitled case cleanly.

2. Stub /vault notes for /data-first folders (Ryan: 'I don't want needs
   review and a fix-me message, it just needs to create the empty folder
   note and give it the automatic alias'): handle_data_created now writes
   just , matching the
   existing convention across the vault (e.g. R.32.22 Alcohol.md). Dropped
   the needs-review banner and the #fix-me inbox flagging entirely, along
   with the now-dead _find_nearest_inbox_note/INBOX_ID_RE.

Verified both Untitled scenarios live: real OS rename from Untitled now
mirrors as a single create at the destination name; two-separate-creates
(Untitled then real name, Untitled later deleted) leaves no orphan in
/data and produces exactly one ntfy for the real folder.
This commit is contained in:
Hermes
2026-09-22 10:53:25 -04:00
parent 49c46042b5
commit 8ffba8f7ee
+50 -61
View File
@@ -19,10 +19,10 @@ FORWARD (/vault -> /data):
REVERSE (/data -> /vault): REVERSE (/data -> /vault):
- a new folder appears in /data whose basename matches the `SYS.AC.ID ` - 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 Johnny Decimal pattern, with NO /vault counterpart yet (e.g. dropped in
over SMB) -> auto-create a minimal /vault stub folder + folder-note, over SMB) -> auto-create a minimal /vault stub folder + folder-note
tagged "needs review", AND append a `#fix-me` line to the nearest `.01` with just `aliases: [<title with ID stripped>]` frontmatter (no
Inbox note in /vault (category-level first, then area/system, falling "needs review" banner, no inbox #fix-me flagging — confirmed with
back to the top-level System inbox). Ryan 2026-09-22: keep it quiet, just the empty note + alias).
- Folders inside any `.09`-pattern archive folder are never auto-stubbed - Folders inside any `.09`-pattern archive folder are never auto-stubbed
(archives are meant to hold retired stuff, not grow new tracked IDs) — (archives are meant to hold retired stuff, not grow new tracked IDs) —
this also breaks the feedback loop from the forward-delete archive move. 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 CAT_RE = vl.CAT_RE
ARCHIVE_ID_RE = re.compile(r'^([RJFI])\.(\d\d)\.09 ') # e.g. "F.25.09 Archive" 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 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 # 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 # 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) rel = os.path.relpath(path, VAULT)
if rel.split(os.sep)[0] == vl.SYSTEM_META: if rel.split(os.sep)[0] == vl.SYSTEM_META:
return # "0 System area" never mirrors to /data, by design 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) data_path = os.path.join(DATA, rel)
if os.path.isdir(data_path): if os.path.isdir(data_path):
return # already mirrored (duplicate event or pre-existing) 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_src = os.path.join(DATA, rel_src)
data_dest = os.path.join(DATA, rel_dest) data_dest = os.path.join(DATA, rel_dest)
if not os.path.isdir(data_src): 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): if os.path.exists(data_dest):
log(f"SKIP move mirror (dest exists): {rel_src} -> {rel_dest}") log(f"SKIP move mirror (dest exists): {rel_src} -> {rel_dest}")
return return
@@ -417,43 +451,10 @@ def handle_vault_deleted(path):
# REVERSE: /data -> /vault # REVERSE: /data -> /vault
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _find_nearest_inbox_note(rel_path): def _id_title(idname):
""" """'R.32.20 Recreational drugs' -> 'Recreational drugs' (ID stripped)."""
Inbox destination is ALWAYS the immediate owning category of the new m = ID_RE.match(idname)
/data folder (same rule as archives: create the missing '.01 Inbox' return idname[m.end():].strip() if m else idname
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 handle_data_created(path): def handle_data_created(path):
@@ -480,27 +481,15 @@ def handle_data_created(path):
return return
note = os.path.join(vault_path, base + ".md") note = os.path.join(vault_path, base + ".md")
os.makedirs(vault_path, exist_ok=True) 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: with open(note, "w", encoding="utf-8") as f:
f.write(f"# {base}\n\n" f.write(f"---\naliases:\n - {alias}\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")
log(f"STUBBED /vault note for new /data folder: {rel}") log(f"STUBBED /vault note for new /data folder: {rel}")
commit_paths = [note] _git_commit(f"vault_sync_daemon: stubbed {base}", [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)
ntfy_post( ntfy_post(
"Vault sync: needs review", "Vault sync: new note",
f"New /data folder with no vault note: {rel}\nStub created, flagged in nearest inbox.", f"New /data folder with no vault note: {rel}\nAuto-created stub with alias \"{alias}\".",
tags="package,warning", tags="package,sparkles",
priority="high",
) )