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:
Hermes
2026-09-15 21:56:27 -04:00
parent abf9569a9a
commit 069552d1b4
3 changed files with 634 additions and 11 deletions
+120 -10
View File
@@ -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):