- 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).
335 lines
13 KiB
Python
335 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
vault_ops.py — write operations for the `vault` CLI.
|
|
Clobber-safe by construction: refuse if destination exists, refuse if source missing.
|
|
Every op is dry-run unless execute=True. Operates on BOTH /vault and /data + manifest.
|
|
"""
|
|
import os, re, sys, subprocess
|
|
_SELF_DIR = os.path.dirname(os.path.realpath(__file__))
|
|
sys.path.insert(0, _SELF_DIR)
|
|
import vault_lib as vl
|
|
|
|
WIKILINK_RE = re.compile(r'(!?\[\[)([^\]#|]+)((?:#[^\]|]+)?(?:\|[^\]]+)?)(\]\])')
|
|
|
|
|
|
def _die(m):
|
|
print("ERROR: " + m, file=sys.stderr); sys.exit(1)
|
|
|
|
|
|
def _sys_root(letter):
|
|
s = vl.walk_structure(vl.VAULT, include_meta=True)["systems"]
|
|
if letter not in s:
|
|
_die(f"unknown system '{letter}'")
|
|
return os.path.join(vl.VAULT, s[letter])
|
|
|
|
|
|
def _find_id_folder(root, code):
|
|
for dp, dns, fns in os.walk(root):
|
|
dns[:] = [d for d in dns if d not in vl.SKIP_DIRS]
|
|
if vl.id_code(os.path.basename(dp)) == code:
|
|
return dp
|
|
return None
|
|
|
|
|
|
def _find_cat_folder(root, catcode):
|
|
for dp, dns, fns in os.walk(root):
|
|
dns[:] = [d for d in dns if d not in vl.SKIP_DIRS]
|
|
if vl.cat_code(os.path.basename(dp)) == catcode:
|
|
return dp
|
|
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
|
|
for p in vl.all_notes(vl.VAULT):
|
|
try:
|
|
txt = open(p, encoding="utf-8").read()
|
|
except Exception:
|
|
continue
|
|
def repl(m):
|
|
nonlocal n
|
|
if m.group(2).strip() == old_stem:
|
|
n += 1
|
|
return f"{m.group(1)}{new_stem}{m.group(3)}{m.group(4)}"
|
|
return m.group(0)
|
|
new = WIKILINK_RE.sub(repl, txt)
|
|
if new != txt and execute:
|
|
open(p, "w", encoding="utf-8").write(new)
|
|
return n
|
|
|
|
|
|
def _git_commit(msg, execute):
|
|
if not execute:
|
|
return
|
|
subprocess.run(["git", "-C", vl.VAULT, "add", "-A"], check=False)
|
|
subprocess.run(["git", "-C", vl.VAULT, "commit", "-m", msg], check=False)
|
|
|
|
|
|
def op_create(argv, execute):
|
|
if len(argv) < 2:
|
|
_die('usage: vault create <ID> "<title>"')
|
|
code, title = argv[0], argv[1]
|
|
if not vl.ID_RE.match(code + " x"):
|
|
_die(f"'{code}' is not a valid SYS.AC.ID code")
|
|
root = _sys_root(code[0])
|
|
catcode = code[:code.rindex(".")]
|
|
catfolder = _find_cat_folder(root, catcode)
|
|
if not catfolder:
|
|
_die(f"category {catcode} does not exist — create it first")
|
|
if _find_id_folder(root, code):
|
|
_die(f"CLOBBER: ID {code} already exists")
|
|
leaf = f"{code} {title}"
|
|
vpath = os.path.join(catfolder, leaf)
|
|
vnote = os.path.join(vpath, leaf + ".md")
|
|
drel = os.path.relpath(vpath, vl.VAULT)
|
|
dpath = os.path.join(vl.DATA, drel)
|
|
print(f"CREATE /vault: {os.path.relpath(vpath, vl.VAULT)}")
|
|
print(f" folder-note: {leaf}.md")
|
|
print(f" /data: {drel}")
|
|
if os.path.exists(dpath):
|
|
_die(f"CLOBBER: /data path already exists: {drel}")
|
|
if execute:
|
|
os.makedirs(vpath)
|
|
with open(vnote, "w", encoding="utf-8") as f:
|
|
f.write(f"# {leaf}\n")
|
|
os.makedirs(dpath)
|
|
_git_commit(f"vault create {code} {title}", execute)
|
|
print("EXECUTED.")
|
|
else:
|
|
print("DRY-RUN — nothing changed. Add --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 <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_folder_by_code(root, old, old_kind)
|
|
if not oldf:
|
|
_die(f"source {old_kind} {old} not found")
|
|
|
|
newroot = _sys_root(new[0])
|
|
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(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)
|
|
print(f"MOVE {old_base} -> {new_base}")
|
|
print(f" /vault: {old_drel} -> {new_drel}")
|
|
print(f" /data: {old_drel} -> {new_drel}")
|
|
print(f" backlink rewrite: [[{old_stem}]] -> [[{new_stem}]]")
|
|
n = _rewrite_links(old_stem, new_stem, execute)
|
|
print(f" ({n} backlink(s))")
|
|
if execute:
|
|
# rename folder-note first, then folder
|
|
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):
|
|
os.makedirs(os.path.dirname(os.path.join(vl.DATA, new_drel)), exist_ok=True)
|
|
os.rename(olddata, os.path.join(vl.DATA, new_drel))
|
|
_regen_manifest(execute)
|
|
_git_commit(f"vault move {old} -> {new}", execute)
|
|
print("EXECUTED.")
|
|
else:
|
|
print("DRY-RUN — nothing changed. Add --execute.")
|
|
|
|
|
|
def op_rename(argv, execute):
|
|
if len(argv) < 2:
|
|
_die('usage: vault rename <ID> "<new title>"')
|
|
code, title = argv[0], argv[1]
|
|
root = _sys_root(code[0])
|
|
f = _find_id_folder(root, code)
|
|
if not f:
|
|
_die(f"ID {code} not found")
|
|
old_base = os.path.basename(f)
|
|
new_base = f"{code} {title}"
|
|
if old_base == new_base:
|
|
_die("new title identical to current")
|
|
parent = os.path.dirname(f)
|
|
if os.path.exists(os.path.join(parent, new_base)):
|
|
_die(f"CLOBBER: {new_base} already exists")
|
|
print(f"RENAME {old_base} -> {new_base}")
|
|
n = _rewrite_links(old_base, new_base, execute)
|
|
print(f" ({n} backlink(s))")
|
|
if execute:
|
|
oldnote = os.path.join(f, old_base + ".md")
|
|
if os.path.exists(oldnote):
|
|
os.rename(oldnote, os.path.join(f, new_base + ".md"))
|
|
newf = os.path.join(parent, new_base)
|
|
os.rename(f, newf)
|
|
drel_old = os.path.relpath(f, vl.VAULT)
|
|
drel_new = os.path.relpath(newf, vl.VAULT)
|
|
od = os.path.join(vl.DATA, drel_old)
|
|
if os.path.isdir(od):
|
|
os.rename(od, os.path.join(vl.DATA, drel_new))
|
|
_regen_manifest(execute)
|
|
_git_commit(f"vault rename {code}", execute)
|
|
print("EXECUTED.")
|
|
else:
|
|
print("DRY-RUN — nothing changed. Add --execute.")
|
|
|
|
|
|
def op_sync_data(argv, execute):
|
|
"""Create any /data skeleton dir that a /vault ID/cat/area lacks. Never deletes."""
|
|
v = vl.walk_structure(vl.VAULT, include_meta=False)
|
|
made = []
|
|
for level in ("areas", "cats", "ids"):
|
|
for code, rel in sorted(v[level].items()):
|
|
dpath = os.path.join(vl.DATA, rel)
|
|
if not os.path.isdir(dpath):
|
|
made.append(rel)
|
|
if execute:
|
|
os.makedirs(dpath, exist_ok=True)
|
|
print(f"sync-data: {len(made)} /data dir(s) {'created' if execute else 'to create'}")
|
|
for m in made[:40]:
|
|
print(" + " + m)
|
|
if len(made) > 40:
|
|
print(f" ... and {len(made)-40} more")
|
|
if execute:
|
|
_git_commit(f"vault sync-data ({len(made)} /data dir(s))", execute)
|
|
print("EXECUTED.")
|
|
else:
|
|
print("DRY-RUN — nothing changed. Add --execute.")
|
|
|
|
|
|
def _regen_manifest(execute):
|
|
if execute:
|
|
subprocess.run([sys.executable, os.path.join(_SELF_DIR, "vault-topology.py"), "--execute"], check=False)
|
|
|
|
|
|
def op_manifest(argv, execute):
|
|
subprocess.run([sys.executable, os.path.join(_SELF_DIR, "vault-topology.py")] +
|
|
(["--execute"] if execute else []), check=False)
|
|
|
|
|
|
def run(cmd, argv, execute):
|
|
{
|
|
"create": op_create,
|
|
"move": op_move,
|
|
"rename": op_rename,
|
|
"sync-data": op_sync_data,
|
|
"manifest": op_manifest,
|
|
}[cmd](argv, execute)
|