diff --git a/vault_sync_daemon.py b/vault_sync_daemon.py index d5190fe..19be996 100644 --- a/vault_sync_daemon.py +++ b/vault_sync_daemon.py @@ -120,6 +120,18 @@ _lock = threading.Lock() _recent_events = {} # (kind, path) -> timestamp, for debouncing duplicate inotify events _DEBOUNCE_SECONDS = 3.0 +# Watchdog's inotify cookie-pairing (MOVED_FROM + MOVED_TO -> one on_moved) +# is best-effort and confirmed to fail even on a single, unloaded, plain +# folder rename (see handle_vault_deleted docstring) -- not just under +# burst load like the earlier "Untitled" bug. To recover the rename intent +# from an unpaired delete+create pair, a vault-side ID deletion is held +# here (keyed by JD ID code, e.g. "F.13.13") for RENAME_GRACE_SECONDS +# before it's actually archived, giving a same-ID create a chance to +# claim it as a rename instead. +_pending_id_deletes = {} +_pending_deletes_lock = threading.Lock() +RENAME_GRACE_SECONDS = 15.0 + def _debounced(kind, path): """ @@ -226,6 +238,21 @@ def handle_vault_created(path): # own separate event moments later and mirrors normally, so skipping # this one is enough: no orphan junk in /data, no duplicate ntfy. return + base = os.path.basename(path) + id_code = vl.id_code(base) + if id_code: + with _pending_deletes_lock: + pending = _pending_id_deletes.pop(id_code, None) + if pending is not None: + # A vault-side delete of the SAME JD ID landed within the + # grace window -- this create is the other half of a rename + # that watchdog failed to pair into one on_moved (confirmed + # 2026-09-24, see handle_vault_deleted). Recover the rename + # instead of doing a normal create-mirror (which would have + # produced a fresh empty stub, orphaning whatever was really + # in the old-named /data folder). + _resolve_as_rename(pending, path, rel) + return data_path = os.path.join(DATA, rel) if os.path.isdir(data_path): return # already mirrored (duplicate event or pre-existing) @@ -410,6 +437,26 @@ def _nearest_archive_note(archive_data_path): def handle_vault_deleted(path): + """ + An ID-shaped folder disappeared from /vault. Per design this normally + means archive the /data copy (see module docstring) -- but a bare + on_deleted this is NOT always a real deletion. Confirmed 2026-09-24: + a plain, unloaded, single-folder Obsidian rename (no burst, no stress + test -- just "Budget & cash-flow tracking" -> "...tracker") produced + an unpaired on_deleted + on_created ~12s apart instead of one + on_moved. This is the SAME watchdog cookie-pairing gap as the earlier + Untitled-placeholder bug, but this time with no load to blame at all + -- so it can happen on any rename, not just bursts. + + Fix: don't archive immediately. Hold the delete in + `_pending_id_deletes` keyed by the JD ID code (e.g. "F.13.13") for + RENAME_GRACE_SECONDS. If a create for a folder with the SAME id code + (different title text is fine -- that's exactly what a rename is) + shows up within the window, handle_vault_created finds it in the + pending dict and treats the pair as the rename it actually was + instead of archiving-then-restubbing. Only after the grace window + passes with no matching create does this actually archive. + """ if _in_skip(path, VAULT): return if not _debounced("vault_deleted", path): @@ -421,6 +468,64 @@ def handle_vault_deleted(path): data_path = os.path.join(DATA, rel) if not os.path.isdir(data_path): return # already handled (duplicate event) or no /data counterpart ever existed + id_code = vl.id_code(base) + if id_code: + with _pending_deletes_lock: + _pending_id_deletes[id_code] = {"path": path, "rel": rel, "base": base} + log(f"PENDING delete (grace window for possible rename pairing): {rel}") + timer = threading.Timer(RENAME_GRACE_SECONDS, _finalize_pending_delete, args=(id_code,)) + timer.daemon = True + timer.start() + return + _archive_deleted_id(path, rel, base) + + +def _finalize_pending_delete(id_code): + """Timer callback: if nothing claimed this id_code as a rename target + within the grace window, it really was just a deletion -- archive now.""" + with _pending_deletes_lock: + entry = _pending_id_deletes.pop(id_code, None) + if entry is None: + return # a matching create already resolved this as a rename + with _lock: + _archive_deleted_id(entry["path"], entry["rel"], entry["base"]) + + +def _resolve_as_rename(pending, new_vault_path, new_rel): + """A delete+create pair shared the same JD ID within the grace window + -- treat it as the rename it actually is: move /data's old-named + folder to the new name (and its folder-note filename to match), + instead of archiving. Falls back to a normal create-mirror if /data + has no old-named counterpart to move.""" + old_rel = pending["rel"] + data_src = os.path.join(DATA, old_rel) + data_dest = os.path.join(DATA, new_rel) + if not os.path.isdir(data_src): + handle_vault_created(new_vault_path) + return + if os.path.exists(data_dest): + log(f"SKIP rename-recovery mirror (dest exists): {old_rel} -> {new_rel}") + return + os.makedirs(os.path.dirname(data_dest), exist_ok=True) + os.rename(data_src, data_dest) + old_base = pending["base"] + new_base = os.path.basename(new_vault_path) + old_note = os.path.join(data_dest, old_base + ".md") + new_note = os.path.join(data_dest, new_base + ".md") + if old_base != new_base and os.path.isfile(old_note) and not os.path.exists(new_note): + os.rename(old_note, new_note) + log(f"RECOVERED rename (delete+create pair, same ID {vl.id_code(new_base)}): {old_rel} -> {new_rel}") + ntfy_post( + "Vault sync: renamed", + f"/data: {old_rel} -> {new_rel}", + tags="package", + ) + + +def _archive_deleted_id(path, rel, base): + data_path = os.path.join(DATA, rel) + if not os.path.isdir(data_path): + return # nothing to archive -- claimed by a rename, or already gone archive_dir, created_note = _find_nearest_archive_dir(rel) dest = os.path.join(archive_dir, base) if os.path.exists(dest):