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).
This commit is contained in:
@@ -10,7 +10,9 @@ Subcommands:
|
|||||||
vault search <query> find IDs/notes by code, title, or content
|
vault search <query> find IDs/notes by code, title, or content
|
||||||
vault show <ID> show an ID's note + its /data home + blob count
|
vault show <ID> show an ID's note + its /data home + blob count
|
||||||
vault create <ID> "<title>" create ID note (folder+folder-note) in /vault AND /data
|
vault create <ID> "<title>" 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 rename <ID> "<title>" retitle in place, rewrite backlinks
|
||||||
vault sync-data reconcile /data skeleton to match manifest (create missing dirs)
|
vault sync-data reconcile /data skeleton to match manifest (create missing dirs)
|
||||||
vault manifest regenerate the topology manifest note
|
vault manifest regenerate the topology manifest note
|
||||||
|
|||||||
+120
-10
@@ -39,6 +39,57 @@ def _find_cat_folder(root, catcode):
|
|||||||
return None
|
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):
|
def _rewrite_links(old_stem, new_stem, execute):
|
||||||
"""Rewrite [[old_stem]] -> [[new_stem]] vault-wide. Returns count."""
|
"""Rewrite [[old_stem]] -> [[new_stem]] vault-wide. Returns count."""
|
||||||
n = 0
|
n = 0
|
||||||
@@ -101,24 +152,82 @@ def op_create(argv, execute):
|
|||||||
|
|
||||||
|
|
||||||
def op_move(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:
|
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, 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])
|
root = _sys_root(old[0])
|
||||||
oldf = _find_id_folder(root, old)
|
oldf = _find_folder_by_code(root, old, old_kind)
|
||||||
if not oldf:
|
if not oldf:
|
||||||
_die(f"source ID {old} not found")
|
_die(f"source {old_kind} {old} not found")
|
||||||
|
|
||||||
newroot = _sys_root(new[0])
|
newroot = _sys_root(new[0])
|
||||||
if _find_id_folder(newroot, new):
|
if _find_folder_by_code(newroot, new, new_kind):
|
||||||
_die(f"CLOBBER: destination ID {new} already exists")
|
_die(f"CLOBBER: destination {new_kind} {new} already exists")
|
||||||
newcat = new[:new.rindex(".")]
|
|
||||||
newcatf = _find_cat_folder(newroot, newcat)
|
nested = _nested_jd_children(oldf)
|
||||||
if not newcatf:
|
if nested:
|
||||||
_die(f"destination category {newcat} does not exist")
|
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)
|
old_base = os.path.basename(oldf)
|
||||||
title = old_base.split(" ", 1)[1] if " " in old_base else ""
|
title = old_base.split(" ", 1)[1] if " " in old_base else ""
|
||||||
new_base = f"{new} {title}".strip()
|
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_stem, new_stem = old_base, new_base
|
||||||
old_drel = os.path.relpath(oldf, vl.VAULT)
|
old_drel = os.path.relpath(oldf, vl.VAULT)
|
||||||
new_drel = os.path.relpath(new_folder, 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")
|
oldnote = os.path.join(oldf, old_base + ".md")
|
||||||
if os.path.exists(oldnote):
|
if os.path.exists(oldnote):
|
||||||
os.rename(oldnote, os.path.join(oldf, new_base + ".md"))
|
os.rename(oldnote, os.path.join(oldf, new_base + ".md"))
|
||||||
|
os.makedirs(newparentf, exist_ok=True)
|
||||||
os.rename(oldf, new_folder)
|
os.rename(oldf, new_folder)
|
||||||
olddata = os.path.join(vl.DATA, old_drel)
|
olddata = os.path.join(vl.DATA, old_drel)
|
||||||
if os.path.isdir(olddata):
|
if os.path.isdir(olddata):
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user