From 069552d1b4a1150049869e0008efa828d9b61aaa Mon Sep 17 00:00:00 2001 From: Hermes Date: Tue, 15 Sep 2026 21:55:21 -0400 Subject: [PATCH] vault move: support area/category/ID codes on either side, incl. cross-level promotion - vault move now accepts any JD code kind (area/cat/ID) on both old and new, not just ID->ID. Enables e.g. 'vault move F.50.09 F.59' to promote an ID to a standalone category. - Refuses if the source has nested JD-coded children (would orphan their codes -- that's a bulk renumber, a different and riskier operation). - Also commits vault_sync_daemon.py (inotify bidirectional /vault<->/data sync service, with ntfy notifications on rename/archive/stub/error). --- vault | 4 +- vault_ops.py | 130 ++++++++++- vault_sync_daemon.py | 511 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 634 insertions(+), 11 deletions(-) create mode 100644 vault_sync_daemon.py diff --git a/vault b/vault index 29ea5a8..eff1f66 100755 --- a/vault +++ b/vault @@ -10,7 +10,9 @@ Subcommands: vault search find IDs/notes by code, title, or content vault show show an ID's note + its /data home + blob count vault create "" create ID note (folder+folder-note) in /vault AND /data - vault move <oldID> <newID> renumber: rename note+folder, rewrite backlinks, move /data dir + vault move <oldCode> <newCode> renumber/promote: area, category, or ID code, either side (e.g. + cross-level "vault move F.50.09 F.59" promotes an ID to a + standalone category); rewrites backlinks, moves /data dir vault rename <ID> "<title>" retitle in place, rewrite backlinks vault sync-data reconcile /data skeleton to match manifest (create missing dirs) vault manifest regenerate the topology manifest note diff --git a/vault_ops.py b/vault_ops.py index 0a2d085..2928c0b 100644 --- a/vault_ops.py +++ b/vault_ops.py @@ -39,6 +39,57 @@ def _find_cat_folder(root, catcode): return None +# --- code-kind helpers for `move`, which supports ID, category, AND area +# codes as either side — including cross-level promotion/demotion (e.g. +# "F.50.09" -> "F.59": an ID promoted to a standalone category). --- +ID_CODE_RE = re.compile(r'^[RJFI]\.\d\d\.\d\d$') +CAT_CODE_RE = re.compile(r'^[RJFI]\.\d\d$') +AREA_CODE_RE = re.compile(r'^[RJFI]\.\d$') + + +def _code_kind(code): + """'id' / 'cat' / 'area' / None (not a recognized code shape).""" + if ID_CODE_RE.match(code): + return "id" + if CAT_CODE_RE.match(code): + return "cat" + if AREA_CODE_RE.match(code): + return "area" + return None + + +def _find_folder_by_code(root, code, kind): + code_fn = {"id": vl.id_code, "cat": vl.cat_code, "area": vl.area_code}[kind] + for dp, dns, fns in os.walk(root): + dns[:] = [d for d in dns if d not in vl.SKIP_DIRS] + if code_fn(os.path.basename(dp)) == code: + return dp + return None + + +def _nested_jd_children(folder): + """ + List any descendant folder (relative path) whose basename is itself + JD-coded (area/category/ID shaped). Moving/renumbering a category or + area whose children's own folder names embed the OLD number (e.g. + "F.50.11 Ryan" inside category "F.50 Family") would orphan those + children's codes — that's a bulk-renumber operation, not a simple + move, so `move` refuses rather than silently leaving stale numbers. + An ID-kind source is always safe here since IDs never have nested + JD-coded children by convention; the check is generic and simply + finds nothing in that case. + """ + found = [] + for dp, dns, fns in os.walk(folder): + dns[:] = [d for d in dns if d not in vl.SKIP_DIRS] + if dp == folder: + continue + base = os.path.basename(dp) + if vl.ID_RE.match(base) or vl.CAT_RE.match(base) or vl.AREA_RE.match(base): + found.append(os.path.relpath(dp, folder)) + return found + + def _rewrite_links(old_stem, new_stem, execute): """Rewrite [[old_stem]] -> [[new_stem]] vault-wide. Returns count.""" n = 0 @@ -101,24 +152,82 @@ def op_create(argv, execute): def op_move(argv, execute): + """ + Renumber/move an area, category, or ID folder to a new code — including + cross-level promotion/demotion (e.g. an ID promoted to a standalone + category: `vault move F.50.09 F.59`). Same-level renumbers (ID->ID, + cat->cat, area->area) work as before; the source and destination no + longer have to be the same kind. + + Refuses (clobber-safe) if: + - the destination code already exists anywhere in that system + - the source has JD-coded descendants (moving a category/area that + still has numbered children would orphan their codes — that's a + bulk-renumber, a different and much riskier operation, not this one) + - moving cross-system (letter must match; there's no valid target + parent on a different system) + """ if len(argv) < 2: - _die("usage: vault move <oldID> <newID>") + _die("usage: vault move <oldCode> <newCode> (ID, category, or area code; can differ in kind)") old, new = argv[0], argv[1] + old_kind = _code_kind(old) + new_kind = _code_kind(new) + if not old_kind: + _die(f"'{old}' is not a recognized area/category/ID code (expected SYS.N, SYS.NN, or SYS.NN.NN)") + if not new_kind: + _die(f"'{new}' is not a recognized area/category/ID code (expected SYS.N, SYS.NN, or SYS.NN.NN)") + if old[0] != new[0]: + _die(f"cross-system move not supported: {old} is system '{old[0]}', {new} is system '{new[0]}'") + root = _sys_root(old[0]) - oldf = _find_id_folder(root, old) + oldf = _find_folder_by_code(root, old, old_kind) if not oldf: - _die(f"source ID {old} not found") + _die(f"source {old_kind} {old} not found") + newroot = _sys_root(new[0]) - if _find_id_folder(newroot, new): - _die(f"CLOBBER: destination ID {new} already exists") - newcat = new[:new.rindex(".")] - newcatf = _find_cat_folder(newroot, newcat) - if not newcatf: - _die(f"destination category {newcat} does not exist") + if _find_folder_by_code(newroot, new, new_kind): + _die(f"CLOBBER: destination {new_kind} {new} already exists") + + nested = _nested_jd_children(oldf) + if nested: + kind_word = {"id": "ID", "cat": "category", "area": "area"}[old_kind] + _die( + f"{old} ({kind_word}) has {len(nested)} JD-coded child folder(s) " + f"(e.g. {nested[0]}) — moving it would orphan their codes. " + f"This needs a bulk renumber, not `move`. Refusing." + ) + + if old_kind != new_kind: + print(f"NOTE: cross-level move — {old} is a {old_kind}, {new} is a {new_kind}.") + + # Destination's PARENT folder must exist. For a same-kind move this is + # "the category/area that will contain the ID/category". For a + # cross-level promotion (e.g. id -> cat), the parent is one level up + # from what `new`'s own kind would normally sit under — i.e. new's + # area, found the normal way regardless of what kind old was. + # NB: parent code can't be derived by slicing at the last '.' — a + # category code like "F.58" has only ONE dot, so that would yield "F" + # (a system letter) instead of "F.5" (the area it belongs to). Derive + # it from the numeric digits directly instead. + if new_kind == "area": + newparentf = newroot # area's parent is the system root itself + else: + if new_kind == "id": + parent_kind = "cat" + parent_code = new[: new.rindex(".")] # "F.58.03" -> "F.58" + else: # new_kind == "cat" + parent_kind = "area" + catnum = new.split(".", 1)[1] # "F.58" -> "58" + parent_code = f"{new[0]}.{catnum[0]}" # -> "F.5" + found_parent = _find_folder_by_code(newroot, parent_code, parent_kind) + if not found_parent: + _die(f"destination parent {parent_kind} {parent_code} does not exist — create it first") + newparentf = found_parent + old_base = os.path.basename(oldf) title = old_base.split(" ", 1)[1] if " " in old_base else "" new_base = f"{new} {title}".strip() - new_folder = os.path.join(newcatf, new_base) + new_folder = os.path.join(newparentf, new_base) old_stem, new_stem = old_base, new_base old_drel = os.path.relpath(oldf, vl.VAULT) new_drel = os.path.relpath(new_folder, vl.VAULT) @@ -133,6 +242,7 @@ def op_move(argv, execute): oldnote = os.path.join(oldf, old_base + ".md") if os.path.exists(oldnote): os.rename(oldnote, os.path.join(oldf, new_base + ".md")) + os.makedirs(newparentf, exist_ok=True) os.rename(oldf, new_folder) olddata = os.path.join(vl.DATA, old_drel) if os.path.isdir(olddata): diff --git a/vault_sync_daemon.py b/vault_sync_daemon.py new file mode 100644 index 0000000..9b3f3e2 --- /dev/null +++ b/vault_sync_daemon.py @@ -0,0 +1,511 @@ +#!/usr/bin/env python3 +""" +vault_sync_daemon.py — long-running inotify (watchdog) daemon that keeps +/vault (JDex notes, git-backed) and /data (blob storage, Duplicati-backed) +structurally mirrored, in BOTH directions, for changes made by ANY tool +(Obsidian, SMB client, manual mv, not just the `vault` CLI). + +Rules (confirmed with Ryan 2026-09-15): + +FORWARD (/vault -> /data): + - rename/move of an ID folder in /vault -> mirror the rename in /data. + - delete of an ID folder in /vault -> do NOT delete /data's copy. + Instead: move the /data folder into the NEAREST existing `.09` archive + folder up the tree (category-level first, then area/system; create a + category-level `.09 Archive` in /data if none exists at any level yet), + and append a note to the nearest `.09` archive's /vault folder-note + markdown recording what was archived, when, and where. + +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). + - 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. + - Generic non-ID subfolders (e.g. "Documents/" inside an existing ID + folder) are ignored entirely — normal blob organization, not JD structure. + +Idempotent by construction: every action checks target state first, so a +duplicate/replayed event from the daemon's own writes is a safe no-op. + +Run: python3 vault_sync_daemon.py (foreground; systemd unit wraps this) +Logs: /home/hermes/.hermes/logs/vault-sync-daemon.log +""" +import os +import re +import sys +import time +import shutil +import subprocess +import datetime +import threading + +_SELF_DIR = os.path.dirname(os.path.realpath(__file__)) +sys.path.insert(0, _SELF_DIR) +import vault_lib as vl + +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler, DirCreatedEvent, DirMovedEvent, DirDeletedEvent + +LOG_PATH = "/home/hermes/.hermes/logs/vault-sync-daemon.log" +os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True) + +NTFY_TOKEN_PATH = "/home/hermes/.hermes/secrets/ntfy-vault-token" +NTFY_URL = "https://ntfy.pandu.ski/vault" + + +def ntfy_post(title, message, tags="package", priority="default"): + """Fire-and-forget ntfy notification. Never raises — a notify failure + must never block or crash the daemon's actual sync work.""" + try: + token = open(NTFY_TOKEN_PATH, encoding="utf-8").read().strip() + except Exception as e: + log(f"ntfy_post: could not read token: {e}") + return + try: + subprocess.run( + ["curl", "-fsS", "--max-time", "5", "-X", "POST", + "-H", f"Authorization: Bearer {token}", + "-H", f"Title: {title}", + "-H", f"Tags: {tags}", + "-H", f"Priority: {priority}", + "-d", message, + NTFY_URL], + check=False, capture_output=True, timeout=8, + ) + except Exception as e: + log(f"ntfy_post failed: {e}") + +VAULT = vl.VAULT +DATA = vl.DATA +SKIP_DIRS = vl.SKIP_DIRS +DATA_EXEMPT = vl.DATA_EXEMPT + +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 ') + +_lock = threading.Lock() +_recent_events = {} # (kind, path) -> timestamp, for debouncing duplicate inotify events +_DEBOUNCE_SECONDS = 3.0 + + +def _debounced(kind, path): + """ + Return True (and record) if this (kind, path) event was NOT seen in the + last _DEBOUNCE_SECONDS — inotify/watchdog can emit duplicate CREATE + events for a single mkdir, so every handler must be idempotent AND + debounced to avoid double-processing (e.g. flagging the same new /data + folder in two different inbox notes). + """ + now = time.time() + key = (kind, path) + last = _recent_events.get(key) + _recent_events[key] = now + # opportunistic cleanup so this dict never grows unbounded + if len(_recent_events) > 2000: + cutoff = now - _DEBOUNCE_SECONDS + for k, v in list(_recent_events.items()): + if v < cutoff: + del _recent_events[k] + return last is None or (now - last) > _DEBOUNCE_SECONDS + + +def log(msg): + line = f"{datetime.datetime.now().isoformat(timespec='seconds')} {msg}" + print(line, flush=True) + try: + with open(LOG_PATH, "a", encoding="utf-8") as f: + f.write(line + "\n") + except Exception: + pass + + +def _git_commit(msg, paths=None): + """ + Commit only the specific paths the daemon itself touched — never a + blanket `git add -A`, so a human's concurrent in-progress edits + elsewhere in the vault are never swept into an unrelated auto-commit. + """ + if not paths: + return + subprocess.run(["git", "-C", VAULT, "add", "--"] + list(paths), check=False) + diff = subprocess.run(["git", "-C", VAULT, "diff", "--cached", "--quiet"], check=False) + if diff.returncode == 0: + return # nothing staged + subprocess.run(["git", "-C", VAULT, "commit", "-m", msg], check=False) + + +def _in_skip(path, root): + rel = os.path.relpath(path, root) + parts = rel.split(os.sep) + return any(p in SKIP_DIRS for p in parts) + + +def _under_archive(rel_path): + """True if any path component matches the '<SYS.AC>.09 ' archive pattern.""" + for part in rel_path.split(os.sep): + if ARCHIVE_ID_RE.match(part): + return True + return False + + +def _under_data_exempt(rel_path): + top = rel_path.split(os.sep)[0] + return top in DATA_EXEMPT + + +# --------------------------------------------------------------------------- +# FORWARD: /vault -> /data +# --------------------------------------------------------------------------- + +def handle_vault_created(path): + """ + A new AREA/CATEGORY/ID-shaped folder appeared in /vault (e.g. created + via Obsidian, not the `vault` CLI) with no /data counterpart yet. + Mirror it: create the matching empty folder in /data. This is the + forward-create leg that was missing — only move/delete were wired up + originally, so a brand-new folder (like "R.6 Test") never propagated. + """ + if not os.path.isdir(path): + return + if _in_skip(path, VAULT): + return + if not _debounced("vault_created", path): + return + rel = os.path.relpath(path, VAULT) + base = os.path.basename(path) + if not (ID_RE.match(base) or CAT_RE.match(base) or vl.AREA_RE.match(base)): + return # not a JD-shaped folder — ignore (e.g. "Documents" subfolder) + data_path = os.path.join(DATA, rel) + if os.path.isdir(data_path): + return # already mirrored (duplicate event or pre-existing) + parent = os.path.dirname(data_path) + if not os.path.isdir(parent): + log(f"SKIP create mirror (parent missing in /data): {rel}") + return + os.makedirs(data_path, exist_ok=True) + log(f"MIRRORED create /data: {rel}") + ntfy_post( + "Vault sync: new folder", + f"Created in /vault, mirrored to /data: {rel}", + tags="sparkles", + ) + + +def handle_vault_moved(src_path, dest_path): + if _in_skip(src_path, VAULT) or _in_skip(dest_path, VAULT): + return + rel_src = os.path.relpath(src_path, VAULT) + rel_dest = os.path.relpath(dest_path, VAULT) + 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 + if os.path.exists(data_dest): + log(f"SKIP move mirror (dest exists): {rel_src} -> {rel_dest}") + return + os.makedirs(os.path.dirname(data_dest), exist_ok=True) + os.rename(data_src, data_dest) + log(f"MIRRORED rename /data: {rel_src} -> {rel_dest}") + ntfy_post( + "Vault sync: renamed", + f"/data: {rel_src} -> {rel_dest}", + tags="package", + ) + + +def _category_title(catname): + """'F.22 Fun lists' -> 'Fun lists'""" + m = CAT_RE.match(catname) + return catname[m.end():].strip() if m else catname + + +def _find_nearest_archive_dir(rel_path): + """ + Archive destination is ALWAYS the immediate owning category of the + deleted ID (one level up from the ID folder) — never a higher-level + ancestor's archive, even if one already exists there. If that category + has no '<SYS.AC>.09 ' archive folder yet, create it right there + (materializing the missing JD archive slot), matching Ryan's rule: + "create missing .01s and .09s when they're missing", not borrow one + further up the tree. + Returns (archive_data_path, vault_note_path_or_None). + vault_note_path is set only if this call just created a NEW archive + slot's folder-note (so the caller knows to git-add it). + """ + parts = rel_path.split(os.sep) + if len(parts) < 2: + return (DATA, None) # ID sits at top level (shouldn't happen) + 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 (cat_data if os.path.isdir(cat_data) else DATA, None) + sysletter, catnum = m.group(1), m.group(2) + # already has an archive slot in this category? + if os.path.isdir(cat_vault): + for name in sorted(os.listdir(cat_vault)): + if ARCHIVE_ID_RE.match(name): + dpath = os.path.join(cat_data, name) + os.makedirs(dpath, exist_ok=True) + return (dpath, None) + # missing — create it in THIS category, not a higher one + archive_name = f"{sysletter}.{catnum}.09 Archive for {_category_title(catname)}" + vpath = os.path.join(cat_vault, archive_name) + vnote = os.path.join(vpath, archive_name + ".md") + created_note = None + if not os.path.exists(vnote): + os.makedirs(vpath, exist_ok=True) + with open(vnote, "w", encoding="utf-8") as f: + f.write(f"# {archive_name}\n\n" + f"Auto-created by vault_sync_daemon on " + f"{datetime.datetime.now().isoformat(timespec='seconds')} " + f"as the archive destination for a deleted ID in this category.\n") + log(f"CREATED category archive slot: {os.path.relpath(vpath, VAULT)}") + created_note = vnote + archive_path = os.path.join(cat_data, archive_name) + os.makedirs(archive_path, exist_ok=True) + return (archive_path, created_note) + + +def _nearest_archive_note(archive_data_path): + """Given an archive dir in /data, return the matching /vault folder-note path, if any.""" + rel = os.path.relpath(archive_data_path, DATA) + vault_dir = os.path.join(VAULT, rel) + if not os.path.isdir(vault_dir): + return None + note = os.path.join(vault_dir, os.path.basename(vault_dir) + ".md") + return note if os.path.exists(note) else None + + +def handle_vault_deleted(path): + if _in_skip(path, VAULT): + return + if not _debounced("vault_deleted", path): + return + rel = os.path.relpath(path, VAULT) + base = os.path.basename(path) + if not ID_RE.match(base): + return # only care about ID-folder deletions + 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 + archive_dir, created_note = _find_nearest_archive_dir(rel) + dest = os.path.join(archive_dir, base) + if os.path.exists(dest): + dest = os.path.join(archive_dir, f"{base} (archived {int(time.time())})") + shutil.move(data_path, dest) + log(f"ARCHIVED /data: {rel} -> {os.path.relpath(dest, DATA)}") + note = _nearest_archive_note(archive_dir) + ts = datetime.datetime.now().isoformat(timespec="seconds") + entry = (f"\n- **Archived {ts}:** `{base}` (deleted from /vault) — " + f"data preserved at `/data/{os.path.relpath(dest, DATA)}`\n") + commit_paths = [] + if note: + with open(note, "a", encoding="utf-8") as f: + f.write(entry) + log(f"NOTED archive in: {os.path.relpath(note, VAULT)}") + commit_paths.append(note) + if created_note: + commit_paths.append(created_note) + _git_commit(f"vault_sync_daemon: archived {base}", commit_paths) + ntfy_post( + "Vault sync: archived", + f"Deleted from /vault: {base}\nData preserved at /data/{os.path.relpath(dest, DATA)}", + tags="package,wastebasket", + ) + + +# --------------------------------------------------------------------------- +# 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 handle_data_created(path): + if not os.path.isdir(path): + return + if _in_skip(path, DATA): + return + if not _debounced("data_created", path): + return # duplicate inotify event for the same mkdir, ignore + rel = os.path.relpath(path, DATA) + if _under_data_exempt(rel): + return + if _under_archive(rel): + return # never auto-stub inside archive folders + base = os.path.basename(path) + if not ID_RE.match(base): + return # only ID-shaped folders trigger reverse-sync + vault_path = os.path.join(VAULT, rel) + if os.path.isdir(vault_path): + return # already has a /vault counterpart + catcode_root = os.path.dirname(vault_path) + if not os.path.isdir(catcode_root): + log(f"SKIP stub (parent category folder missing in /vault): {rel}") + return + note = os.path.join(vault_path, base + ".md") + os.makedirs(vault_path, exist_ok=True) + ts = datetime.datetime.now().isoformat(timespec="seconds") + 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") + 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) + 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", + ) + + +# --------------------------------------------------------------------------- +# watchdog wiring +# --------------------------------------------------------------------------- + +class VaultHandler(FileSystemEventHandler): + def on_created(self, event): + if not event.is_directory: + return + with _lock: + try: + time.sleep(0.5) # let Obsidian finish writing the folder-note + handle_vault_created(event.src_path) + except Exception as e: + log(f"ERROR handle_vault_created: {e}") + ntfy_post( + "Vault sync ERROR", + f"handle_vault_created failed:\n{event.src_path}\n{e}", + tags="rotating_light", + priority="urgent", + ) + + def on_moved(self, event): + if not event.is_directory: + return + with _lock: + try: + handle_vault_moved(event.src_path, event.dest_path) + except Exception as e: + log(f"ERROR handle_vault_moved: {e}") + ntfy_post( + "Vault sync ERROR", + f"handle_vault_moved failed:\n{event.src_path} -> {event.dest_path}\n{e}", + tags="rotating_light", + priority="urgent", + ) + + def on_deleted(self, event): + if not event.is_directory: + return + with _lock: + try: + handle_vault_deleted(event.src_path) + except Exception as e: + log(f"ERROR handle_vault_deleted: {e}") + ntfy_post( + "Vault sync ERROR", + f"handle_vault_deleted failed:\n{event.src_path}\n{e}", + tags="rotating_light", + priority="urgent", + ) + + +class DataHandler(FileSystemEventHandler): + def on_created(self, event): + if not event.is_directory: + return + with _lock: + try: + # small settle delay so a batch mkdir/copy isn't caught half-written + time.sleep(0.5) + handle_data_created(event.src_path) + except Exception as e: + log(f"ERROR handle_data_created: {e}") + ntfy_post( + "Vault sync ERROR", + f"handle_data_created failed:\n{event.src_path}\n{e}", + tags="rotating_light", + priority="urgent", + ) + + +def main(): + log("vault_sync_daemon starting") + observer = Observer() + observer.schedule(VaultHandler(), VAULT, recursive=True) + observer.schedule(DataHandler(), DATA, recursive=True) + observer.start() + log(f"watching {VAULT} and {DATA}") + ntfy_post("Vault sync daemon started", f"Watching {VAULT} and {DATA}", tags="rocket", priority="low") + try: + while True: + time.sleep(5) + except KeyboardInterrupt: + pass + finally: + observer.stop() + observer.join() + log("vault_sync_daemon stopped") + ntfy_post("Vault sync daemon stopped", "", tags="octagonal_sign", priority="high") + + +if __name__ == "__main__": + main()