#!/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 ""') 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)